mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-21 19:34:56 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76a913608c |
@@ -1,662 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,216 +0,0 @@
|
||||
# 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
@@ -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: Request, pk: int) -> str | None:
|
||||
def thumbnail_etag(request: Any, 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: Request, pk: int) -> str | None:
|
||||
return doc.checksum
|
||||
|
||||
|
||||
def thumbnail_last_modified(request: Request, pk: int) -> datetime | None:
|
||||
def thumbnail_last_modified(request: Any, pk: int) -> datetime | None:
|
||||
"""
|
||||
Returns the filesystem last modified either from cache or from filesystem.
|
||||
Cache should be (slightly?) faster than filesystem
|
||||
|
||||
@@ -621,11 +621,6 @@ 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]
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
+1
-11
@@ -217,7 +217,6 @@ 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."),
|
||||
)
|
||||
|
||||
@@ -235,7 +234,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
blank=False,
|
||||
null=True,
|
||||
unique=False,
|
||||
db_index=True,
|
||||
db_index=False,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text=_(
|
||||
"The number of pages of the document.",
|
||||
@@ -339,9 +338,6 @@ 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"],
|
||||
@@ -1194,12 +1190,6 @@ 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,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import TestCase
|
||||
from unittest import mock
|
||||
@@ -11,7 +10,6 @@ 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
|
||||
|
||||
@@ -139,8 +137,6 @@ 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}/")
|
||||
@@ -150,7 +146,6 @@ 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}/")
|
||||
@@ -331,8 +326,6 @@ 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}/",
|
||||
@@ -346,8 +339,6 @@ 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(
|
||||
|
||||
@@ -767,8 +767,6 @@ 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",
|
||||
@@ -820,8 +818,6 @@ 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,12 +1,8 @@
|
||||
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
|
||||
@@ -17,9 +13,6 @@ 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:
|
||||
@@ -36,18 +29,14 @@ class TestConditionals(DirectoriesMixin, TestCase):
|
||||
mime_type="application/pdf",
|
||||
root_document=root,
|
||||
)
|
||||
request = cast("Request", SimpleNamespace(query_params={}))
|
||||
request = SimpleNamespace(query_params={})
|
||||
|
||||
self.assertEqual(
|
||||
metadata_etag(request, root.id),
|
||||
f"{latest.checksum}:{latest.modified.isoformat()}",
|
||||
)
|
||||
# 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)
|
||||
self.assertEqual(preview_etag(request, root.id), latest.archive_checksum)
|
||||
self.assertEqual(thumbnail_etag(request, root.id), latest.checksum)
|
||||
|
||||
def test_metadata_etag_changes_when_document_modified_changes(self) -> None:
|
||||
doc = Document.objects.create(
|
||||
@@ -55,20 +44,15 @@ class TestConditionals(DirectoriesMixin, TestCase):
|
||||
checksum="same-checksum",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
request = SimpleNamespace(query_params={})
|
||||
|
||||
original_etag = metadata_etag(request, doc.id)
|
||||
new_modified = timezone.now() + timedelta(seconds=5)
|
||||
Document.objects.filter(id=doc.id).update(modified=new_modified)
|
||||
|
||||
second_request = cast("Request", SimpleNamespace(query_params={}))
|
||||
self.assertNotEqual(metadata_etag(second_request, doc.id), original_etag)
|
||||
self.assertNotEqual(metadata_etag(request, doc.id), original_etag)
|
||||
self.assertEqual(
|
||||
metadata_etag(second_request, doc.id),
|
||||
metadata_etag(request, doc.id),
|
||||
f"{doc.checksum}:{new_modified.isoformat()}",
|
||||
)
|
||||
|
||||
@@ -92,13 +76,9 @@ class TestConditionals(DirectoriesMixin, TestCase):
|
||||
root_document=other_root,
|
||||
)
|
||||
|
||||
invalid_request = cast(
|
||||
"Request",
|
||||
SimpleNamespace(query_params={"version": "not-a-number"}),
|
||||
)
|
||||
unrelated_request = cast(
|
||||
"Request",
|
||||
SimpleNamespace(query_params={"version": str(other_version.id)}),
|
||||
invalid_request = SimpleNamespace(query_params={"version": "not-a-number"})
|
||||
unrelated_request = SimpleNamespace(
|
||||
query_params={"version": str(other_version.id)},
|
||||
)
|
||||
|
||||
self.assertIsNone(
|
||||
@@ -125,7 +105,7 @@ class TestConditionals(DirectoriesMixin, TestCase):
|
||||
latest.thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
latest.thumbnail_path.write_bytes(b"thumb")
|
||||
|
||||
request = cast("Request", SimpleNamespace(query_params={}))
|
||||
request = SimpleNamespace(query_params={})
|
||||
with mock.patch(
|
||||
"documents.conditionals.get_thumbnail_modified_key",
|
||||
return_value="thumb-modified-key",
|
||||
|
||||
@@ -2938,69 +2938,6 @@ 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:
|
||||
|
||||
+11
-34
@@ -8,7 +8,7 @@ from typing import Any
|
||||
from documents.models import Document
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rest_framework.request import Request
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
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: Request) -> str | None:
|
||||
def get_request_version_param(request: HttpRequest) -> 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: Request,
|
||||
request: Any,
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
) -> VersionResolution:
|
||||
@@ -86,7 +86,7 @@ def resolve_requested_version_for_root(
|
||||
|
||||
def resolve_effective_document(
|
||||
request_doc: Document,
|
||||
request: Request,
|
||||
request: Any,
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
) -> VersionResolution:
|
||||
@@ -107,41 +107,18 @@ 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: Request,
|
||||
request: Any,
|
||||
*,
|
||||
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:
|
||||
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
|
||||
return VersionResolution(document=None, error=VersionResolutionError.NOT_FOUND)
|
||||
return resolve_effective_document(
|
||||
request_doc,
|
||||
request,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
|
||||
+1
-16
@@ -1040,23 +1040,12 @@ 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=Coalesce(note_count, 0))
|
||||
.annotate(num_notes=Count("notes"))
|
||||
.select_related("correspondent", "storage_path", "document_type", "owner")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
@@ -2068,8 +2057,6 @@ 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 = (
|
||||
@@ -2149,8 +2136,6 @@ 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 = (
|
||||
|
||||
@@ -24,11 +24,7 @@ def apply_assignment_to_document(
|
||||
action: WorkflowAction, annotated with 'has_assign_*' boolean fields
|
||||
"""
|
||||
if action.has_assign_tags:
|
||||
# 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())
|
||||
document.add_nested_tags(action.assign_tags.all())
|
||||
|
||||
if action.assign_correspondent:
|
||||
document.correspondent = action.assign_correspondent
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -1659,11 +1659,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr "ይህ ልማድ አልተገኘም"
|
||||
|
||||
@@ -1659,11 +1659,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr "لم يتم العثور على حقل مخصص"
|
||||
|
||||
@@ -1660,11 +1660,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -1659,11 +1659,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr "Персонализирано поле не е намерено"
|
||||
|
||||
@@ -1660,11 +1660,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "El paquet d'enllaç no està disponible."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-21 12:39\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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 ""
|
||||
msgstr "Zahájeno"
|
||||
|
||||
#: documents/models.py:677
|
||||
msgid "Success"
|
||||
@@ -1661,11 +1661,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "Das Freigabelink-Paket ist nicht verfügbar."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-17 12:35\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "Das Freigabelink-Paket ist nicht verfügbar."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr "Το προσαρμοσμένο πεδίο δε βρέθηκε"
|
||||
|
||||
@@ -1660,11 +1660,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "El paquete de enlace compartido no está disponible."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr "زمینه سفارشی یافت نشد"
|
||||
|
||||
@@ -1659,11 +1659,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -1660,11 +1660,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-21 00:49\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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 ""
|
||||
msgstr "La requête d'arrière-plan IA a expiré."
|
||||
|
||||
#: 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:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
@@ -1705,7 +1705,7 @@ msgstr "rétablir"
|
||||
|
||||
#: paperless/models.py:42
|
||||
msgid "off"
|
||||
msgstr ""
|
||||
msgstr "off"
|
||||
|
||||
#: 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 ""
|
||||
msgstr "Définit la langue de sortie du LLM"
|
||||
|
||||
#: paperless/models.py:370
|
||||
msgid "Sets the LLM timeout in seconds"
|
||||
msgstr ""
|
||||
msgstr "Définit le délai d'attente du LLM en secondes"
|
||||
|
||||
#: 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 ""
|
||||
msgstr "Arrêter le traitement des règles suivantes"
|
||||
|
||||
#: paperless_mail/models.py:308
|
||||
msgid "If True, no further rules will be processed after this one if any document is queued."
|
||||
msgstr ""
|
||||
msgstr "Si Vrai, aucune règle supplémentaire ne sera traitée après celle-ci si un document est mis en attente."
|
||||
|
||||
#: paperless_mail/models.py:334
|
||||
msgid "uid"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr "שדה מותאם אישית לא נמצא"
|
||||
|
||||
@@ -1661,11 +1661,11 @@ msgstr "הרשאות לא מספיקות לשיתוף מסמך %(id)s."
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr "החבילה (Bundle) כבר נמצאת בתהליך עיבוד."
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr "חבילת קישור השיתוף עדיין בהכנה. נא לנסות שוב מאוחר יותר."
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "חבילת קישור השיתוף אינה זמינה."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr "कस्टम फ़ील्ड नहीं मिला"
|
||||
|
||||
@@ -1659,11 +1659,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "Paket linka za dijeljenje nije dostupan."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "A megosztási linkcsomag nem elérhető."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-20 12:47\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "Bundel tautan berbagi tidak tersedia."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
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 ""
|
||||
msgstr "validità uid"
|
||||
|
||||
#: paperless_mail/models.py:350
|
||||
msgid "subject"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr "カスタムフィールドが見つかりません"
|
||||
|
||||
@@ -1660,11 +1660,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr "사용자 지정 필드를 찾을 수 없음"
|
||||
|
||||
@@ -1660,11 +1660,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -1659,11 +1659,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "Dalinimos nuorodų rinkinys yra nepasiekiamas."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -1659,11 +1659,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -1659,11 +1659,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -1659,11 +1659,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "The share link bundle is unavailable. (NO)"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "Pakiet do udostępnienia jest niedostępny."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
msgid "Custom field not found"
|
||||
msgstr "Пользовательское поле не найдено"
|
||||
|
||||
@@ -1660,11 +1660,11 @@ msgstr ""
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "Paket povezav za deljenje ni na voljo."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr "[SQ] The share link bundle is unavailable."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2026-07-15 20:45\n"
|
||||
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
|
||||
"PO-Revision-Date: 2026-07-19 20:57\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:1052
|
||||
#: documents/filters.py:1059
|
||||
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:4554
|
||||
#: documents/views.py:4555
|
||||
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:4564
|
||||
#: documents/views.py:4565
|
||||
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
Reference in New Issue
Block a user