From 7032da53c507ac056b296d69bfe122fd8ddf1da2 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 1 Dec 2025 23:13:18 +0000
Subject: [PATCH 01/36] Documentation: Add v2.20.1 changelog (#11510)
* Changelog v2.20.1 - GHA
* Update changelog.md
---------
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
---
docs/changelog.md | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/docs/changelog.md b/docs/changelog.md
index 3b1578c81..9b325a017 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,30 @@
# Changelog
+## paperless-ngx 2.20.1
+
+### Bug Fixes
+
+- Fix: set search term when using advanced search from global search [@shamoon](https://github.com/shamoon) ([#11503](https://github.com/paperless-ngx/paperless-ngx/pull/11503))
+- Fix: change async handling of select custom field updates [@shamoon](https://github.com/shamoon) ([#11490](https://github.com/paperless-ngx/paperless-ngx/pull/11490))
+- Fix: skip SSL for MariaDB ping in init script [@danielrheinbay](https://github.com/danielrheinbay) ([#11491](https://github.com/paperless-ngx/paperless-ngx/pull/11491))
+- Fix: handle allauth groups location breaking change [@shamoon](https://github.com/shamoon) ([#11471](https://github.com/paperless-ngx/paperless-ngx/pull/11471))
+
+### Dependencies
+
+- docker(deps): Bump astral-sh/uv from 0.9.10-python3.12-trixie-slim to 0.9.11-python3.12-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11450](https://github.com/paperless-ngx/paperless-ngx/pull/11450))
+- Chore(deps): Bump @angular/common from 20.3.12 to 20.3.14 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11481](https://github.com/paperless-ngx/paperless-ngx/pull/11481))
+
+### All App Changes
+
+
+4 changes
+
+- Fix: set search term when using advanced search from global search [@shamoon](https://github.com/shamoon) ([#11503](https://github.com/paperless-ngx/paperless-ngx/pull/11503))
+- Fix: change async handling of select custom field updates [@shamoon](https://github.com/shamoon) ([#11490](https://github.com/paperless-ngx/paperless-ngx/pull/11490))
+- Chore(deps): Bump @angular/common from 20.3.12 to 20.3.14 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11481](https://github.com/paperless-ngx/paperless-ngx/pull/11481))
+- Fix: handle allauth groups location breaking change [@shamoon](https://github.com/shamoon) ([#11471](https://github.com/paperless-ngx/paperless-ngx/pull/11471))
+
+
## paperless-ngx 2.20.0
### Notable Changes
From 3f81b432ece96af8c5d956e8ae15123d5a127440 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Thu, 11 Dec 2025 19:17:55 -0800
Subject: [PATCH 02/36] Fix: normalize SVG tag and attribute names, add version
(#11586)
---
src/paperless/validators.py | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/src/paperless/validators.py b/src/paperless/validators.py
index dea1f5185..beb87d7b3 100644
--- a/src/paperless/validators.py
+++ b/src/paperless/validators.py
@@ -14,10 +14,10 @@ ALLOWED_SVG_TAGS: set[str] = {
"text",
"tspan",
"defs",
- "linearGradient",
- "radialGradient",
+ "lineargradient",
+ "radialgradient",
"stop",
- "clipPath",
+ "clippath",
"use",
"title",
"desc",
@@ -52,14 +52,14 @@ ALLOWED_SVG_ATTRIBUTES: set[str] = {
"y1",
"x2",
"y2",
- "gradientTransform",
- "gradientUnits",
+ "gradienttransform",
+ "gradientunits",
"offset",
"stop-color",
"stop-opacity",
"clip-path",
- "viewBox",
- "preserveAspectRatio",
+ "viewbox",
+ "preserveaspectratio",
"href",
"xlink:href",
"font-family",
@@ -68,6 +68,7 @@ ALLOWED_SVG_ATTRIBUTES: set[str] = {
"text-anchor",
"xmlns",
"xmlns:xlink",
+ "version",
}
From 3b4d958b97164f240b28152fc0b1886dd7402e9f Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Fri, 12 Dec 2025 07:50:51 -0800
Subject: [PATCH 03/36] Performance: avoid unnecessary filename operations on
bulk custom field updates (#11558)
---
src/documents/signals/handlers.py | 55 ++++++++++++++++--
src/documents/tests/test_file_handling.py | 68 +++++++++++++++++++++++
2 files changed, 117 insertions(+), 6 deletions(-)
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 6cafbc1f8..5f2c8b4b2 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -28,6 +28,7 @@ from documents import matching
from documents.caching import clear_document_caches
from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories
+from documents.file_handling import generate_filename
from documents.file_handling import generate_unique_filename
from documents.models import CustomField
from documents.models import CustomFieldInstance
@@ -42,6 +43,7 @@ from documents.models import WorkflowAction
from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware
+from documents.templating.utils import convert_format_str_to_template_format
from documents.workflows.actions import build_workflow_action_context
from documents.workflows.actions import execute_email_action
from documents.workflows.actions import execute_webhook_action
@@ -389,6 +391,19 @@ class CannotMoveFilesException(Exception):
pass
+def _filename_template_uses_custom_fields(doc: Document) -> bool:
+ template = None
+ if doc.storage_path is not None:
+ template = doc.storage_path.path
+ elif settings.FILENAME_FORMAT is not None:
+ template = convert_format_str_to_template_format(settings.FILENAME_FORMAT)
+
+ if not template:
+ return False
+
+ return "custom_fields" in template
+
+
# should be disabled in /src/documents/management/commands/document_importer.py handle
@receiver(models.signals.post_save, sender=CustomFieldInstance, weak=False)
@receiver(models.signals.m2m_changed, sender=Document.tags.through, weak=False)
@@ -399,6 +414,8 @@ def update_filename_and_move_files(
**kwargs,
):
if isinstance(instance, CustomFieldInstance):
+ if not _filename_template_uses_custom_fields(instance.document):
+ return
instance = instance.document
def validate_move(instance, old_path: Path, new_path: Path):
@@ -436,21 +453,47 @@ def update_filename_and_move_files(
old_filename = instance.filename
old_source_path = instance.source_path
+ candidate_filename = generate_filename(instance)
+ candidate_source_path = (
+ settings.ORIGINALS_DIR / candidate_filename
+ ).resolve()
+ if candidate_filename == Path(old_filename):
+ new_filename = Path(old_filename)
+ elif (
+ candidate_source_path.exists()
+ and candidate_source_path != old_source_path
+ ):
+ # Only fall back to unique search when there is an actual conflict
+ new_filename = generate_unique_filename(instance)
+ else:
+ new_filename = candidate_filename
+
# Need to convert to string to be able to save it to the db
- instance.filename = str(generate_unique_filename(instance))
+ instance.filename = str(new_filename)
move_original = old_filename != instance.filename
old_archive_filename = instance.archive_filename
old_archive_path = instance.archive_path
if instance.has_archive_version:
- # Need to convert to string to be able to save it to the db
- instance.archive_filename = str(
- generate_unique_filename(
+ archive_candidate = generate_filename(instance, archive_filename=True)
+ archive_candidate_path = (
+ settings.ARCHIVE_DIR / archive_candidate
+ ).resolve()
+ if archive_candidate == Path(old_archive_filename):
+ new_archive_filename = Path(old_archive_filename)
+ elif (
+ archive_candidate_path.exists()
+ and archive_candidate_path != old_archive_path
+ ):
+ new_archive_filename = generate_unique_filename(
instance,
archive_filename=True,
- ),
- )
+ )
+ else:
+ new_archive_filename = archive_candidate
+
+ instance.archive_filename = str(new_archive_filename)
move_archive = old_archive_filename != instance.archive_filename
else:
diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py
index a1fd3d674..befc7050f 100644
--- a/src/documents/tests/test_file_handling.py
+++ b/src/documents/tests/test_file_handling.py
@@ -16,6 +16,7 @@ from django.utils import timezone
from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories
from documents.file_handling import generate_filename
+from documents.file_handling import generate_unique_filename
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
@@ -1632,6 +1633,73 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
)
+class TestCustomFieldFilenameUpdates(
+ DirectoriesMixin,
+ FileSystemAssertsMixin,
+ TestCase,
+):
+ def setUp(self):
+ self.cf = CustomField.objects.create(
+ name="flavor",
+ data_type=CustomField.FieldDataType.STRING,
+ )
+ self.doc = Document.objects.create(
+ title="document",
+ mime_type="application/pdf",
+ checksum="abc123",
+ )
+ self.cfi = CustomFieldInstance.objects.create(
+ field=self.cf,
+ document=self.doc,
+ value_text="initial",
+ )
+ return super().setUp()
+
+ @override_settings(FILENAME_FORMAT=None)
+ def test_custom_field_not_in_template_skips_filename_work(self):
+ storage_path = StoragePath.objects.create(path="{{created}}/{{ title }}")
+ self.doc.storage_path = storage_path
+ self.doc.save()
+ initial_filename = generate_filename(self.doc)
+ Document.objects.filter(pk=self.doc.pk).update(filename=str(initial_filename))
+ self.doc.refresh_from_db()
+ Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
+ Path(self.doc.source_path).touch()
+
+ with mock.patch("documents.signals.handlers.generate_unique_filename") as m:
+ m.side_effect = generate_unique_filename
+ self.cfi.value_text = "updated"
+ self.cfi.save()
+
+ self.doc.refresh_from_db()
+ self.assertEqual(Path(self.doc.filename), initial_filename)
+ self.assertEqual(m.call_count, 0)
+
+ @override_settings(FILENAME_FORMAT=None)
+ def test_custom_field_in_template_triggers_filename_update(self):
+ storage_path = StoragePath.objects.create(
+ path="{{ custom_fields|get_cf_value('flavor') }}/{{ title }}",
+ )
+ self.doc.storage_path = storage_path
+ self.doc.save()
+ initial_filename = generate_filename(self.doc)
+ Document.objects.filter(pk=self.doc.pk).update(filename=str(initial_filename))
+ self.doc.refresh_from_db()
+ Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
+ Path(self.doc.source_path).touch()
+
+ with mock.patch("documents.signals.handlers.generate_unique_filename") as m:
+ m.side_effect = generate_unique_filename
+ self.cfi.value_text = "updated"
+ self.cfi.save()
+
+ self.doc.refresh_from_db()
+ expected_filename = Path("updated/document.pdf")
+ self.assertEqual(Path(self.doc.filename), expected_filename)
+ self.assertTrue(Path(self.doc.source_path).is_file())
+ self.assertLessEqual(m.call_count, 1)
+
+
class TestPathDateLocalization:
"""
Groups all tests related to the `localize_date` function.
From 402f2ead596e01db5bd6155b323704e4252d3ba2 Mon Sep 17 00:00:00 2001
From: Trenton H <797416+stumpylog@users.noreply.github.com>
Date: Fri, 12 Dec 2025 07:51:45 -0800
Subject: [PATCH 04/36] Fixes the workflow configuration being nested under the
consumption documentation (#11588)
---
docs/configuration.md | 48 +++++++++++++++++++++----------------------
1 file changed, 24 insertions(+), 24 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index 51c00d1a5..0ae474805 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1271,30 +1271,6 @@ within your documents.
Defaults to false.
-## Workflow webhooks
-
-#### [`PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES=`](#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES) {#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES}
-
-: A comma-separated list of allowed schemes for webhooks. This setting
-controls which URL schemes are permitted for webhook URLs.
-
- Defaults to `http,https`.
-
-#### [`PAPERLESS_WEBHOOKS_ALLOWED_PORTS=`](#PAPERLESS_WEBHOOKS_ALLOWED_PORTS) {#PAPERLESS_WEBHOOKS_ALLOWED_PORTS}
-
-: A comma-separated list of allowed ports for webhooks. This setting
-controls which ports are permitted for webhook URLs. For example, if you
-set this to `80,443`, webhooks will only be sent to URLs that use these
-ports.
-
- Defaults to empty list, which allows all ports.
-
-#### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS}
-
-: If set to false, webhooks cannot be sent to internal URLs (e.g., localhost).
-
- Defaults to true, which allows internal requests.
-
### Polling {#polling}
#### [`PAPERLESS_CONSUMER_POLLING=`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}
@@ -1338,6 +1314,30 @@ consumers working on the same file. Configure this to prevent that.
Defaults to 0.5 seconds.
+## Workflow webhooks
+
+#### [`PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES=`](#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES) {#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES}
+
+: A comma-separated list of allowed schemes for webhooks. This setting
+controls which URL schemes are permitted for webhook URLs.
+
+ Defaults to `http,https`.
+
+#### [`PAPERLESS_WEBHOOKS_ALLOWED_PORTS=`](#PAPERLESS_WEBHOOKS_ALLOWED_PORTS) {#PAPERLESS_WEBHOOKS_ALLOWED_PORTS}
+
+: A comma-separated list of allowed ports for webhooks. This setting
+controls which ports are permitted for webhook URLs. For example, if you
+set this to `80,443`, webhooks will only be sent to URLs that use these
+ports.
+
+ Defaults to empty list, which allows all ports.
+
+#### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS}
+
+: If set to false, webhooks cannot be sent to internal URLs (e.g., localhost).
+
+ Defaults to true, which allows internal requests.
+
## Incoming Mail {#incoming_mail}
### Email OAuth {#email_oauth}
From e770ff572eee25b35c86f904d3154c261a5877b8 Mon Sep 17 00:00:00 2001
From: Jan Kleine
Date: Fri, 12 Dec 2025 17:12:23 +0100
Subject: [PATCH 05/36] Documentation: Document missing workflows env variable
and complete diagram (#11554)
---
docs/configuration.md | 12 +++++++++++-
docs/usage.md | 9 +++++++++
2 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index 0ae474805..9b4694b63 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1054,12 +1054,22 @@ should be a valid crontab(5) expression describing when to run.
#### [`PAPERLESS_SANITY_TASK_CRON=`](#PAPERLESS_SANITY_TASK_CRON) {#PAPERLESS_SANITY_TASK_CRON}
-: Configures the scheduled sanity checker frequency.
+: Configures the scheduled sanity checker frequency. The value should be a
+valid crontab(5) expression describing when to run.
: If set to the string "disable", the sanity checker will not run automatically.
Defaults to `30 0 * * sun` or Sunday at 30 minutes past midnight.
+#### [`PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON=`](#PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON) {#PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON}
+
+: Configures the scheduled workflow check frequency. The value should be a
+valid crontab(5) expression describing when to run.
+
+: If set to the string "disable", scheduled workflows will not run.
+
+ Defaults to `5 */1 * * *` or every hour at 5 minutes past the hour.
+
#### [`PAPERLESS_ENABLE_COMPRESSION=`](#PAPERLESS_ENABLE_COMPRESSION) {#PAPERLESS_ENABLE_COMPRESSION}
: Enables compression of the responses from the webserver.
diff --git a/docs/usage.md b/docs/usage.md
index cac2c964f..339dbddde 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -443,6 +443,10 @@ flowchart TD
'Updated'
trigger(s)"}
+ scheduled{"Documents
+ matching
+ trigger(s)"}
+
A[New Document] --> consumption
consumption --> |Yes| C[Workflow Actions Run]
consumption --> |No| D
@@ -455,6 +459,11 @@ flowchart TD
updated --> |Yes| J[Workflow Actions Run]
updated --> |No| K
J --> K[Document Saved]
+ L[Scheduled Task Check
hourly at :05] --> M[Get All Scheduled Triggers]
+ M --> scheduled
+ scheduled --> |Yes| N[Workflow Actions Run]
+ scheduled --> |No| O[Document Saved]
+ N --> O
```
#### Filters {#workflow-trigger-filters}
From 3a1d33225e397f2c320c197c2a79a0fd1fdaac40 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Fri, 12 Dec 2025 08:43:16 -0800
Subject: [PATCH 06/36] Fixhancement: pass ordering to tag children (#11556)
---
src/documents/serialisers.py | 22 +++++++++++++++++++---
1 file changed, 19 insertions(+), 3 deletions(-)
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 3cb2b00af..6265d291c 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -21,6 +21,7 @@ from django.core.validators import MaxLengthValidator
from django.core.validators import RegexValidator
from django.core.validators import integer_validator
from django.db.models import Count
+from django.db.models.functions import Lower
from django.utils.crypto import get_random_string
from django.utils.dateparse import parse_datetime
from django.utils.text import slugify
@@ -38,6 +39,7 @@ from guardian.utils import get_user_obj_perms_model
from rest_framework import fields
from rest_framework import serializers
from rest_framework.fields import SerializerMethodField
+from rest_framework.filters import OrderingFilter
if settings.AUDIT_LOG_ENABLED:
from auditlog.context import set_actor
@@ -575,15 +577,29 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
)
def get_children(self, obj):
filter_q = self.context.get("document_count_filter")
+ request = self.context.get("request")
if filter_q is None:
- request = self.context.get("request")
user = getattr(request, "user", None) if request else None
filter_q = get_document_count_filter_for_user(user)
self.context["document_count_filter"] = filter_q
- serializer = TagSerializer(
+
+ children_queryset = (
obj.get_children_queryset()
.select_related("owner")
- .annotate(document_count=Count("documents", filter=filter_q)),
+ .annotate(document_count=Count("documents", filter=filter_q))
+ )
+
+ view = self.context.get("view")
+ ordering = (
+ OrderingFilter().get_ordering(request, children_queryset, view)
+ if request and view
+ else None
+ )
+ ordering = ordering or (Lower("name"),)
+ children_queryset = children_queryset.order_by(*ordering)
+
+ serializer = TagSerializer(
+ children_queryset,
many=True,
user=self.user,
full_perms=self.full_perms,
From 332136df8b20cb415dca9a41970d8c933de710e4 Mon Sep 17 00:00:00 2001
From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 12 Dec 2025 16:44:49 +0000
Subject: [PATCH 07/36] Auto translate strings
---
src/locale/en_US/LC_MESSAGES/django.po | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po
index 8e745a2e9..1812ebe10 100644
--- a/src/locale/en_US/LC_MESSAGES/django.po
+++ b/src/locale/en_US/LC_MESSAGES/django.po
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-12-10 16:39+0000\n"
+"POT-Creation-Date: 2025-12-12 16:43+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1219,40 +1219,40 @@ msgstr ""
msgid "workflow runs"
msgstr ""
-#: documents/serialisers.py:145
+#: documents/serialisers.py:147
#, python-format
msgid "Invalid regular expression: %(error)s"
msgstr ""
-#: documents/serialisers.py:622
+#: documents/serialisers.py:638
msgid "Invalid color."
msgstr ""
-#: documents/serialisers.py:1808
+#: documents/serialisers.py:1824
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
-#: documents/serialisers.py:1852
+#: documents/serialisers.py:1868
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
-#: documents/serialisers.py:1859
+#: documents/serialisers.py:1875
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
-#: documents/serialisers.py:1876 documents/serialisers.py:1886
+#: documents/serialisers.py:1892 documents/serialisers.py:1902
msgid ""
"Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
-#: documents/serialisers.py:1881
+#: documents/serialisers.py:1897
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
-#: documents/serialisers.py:1996
+#: documents/serialisers.py:2012
msgid "Invalid variable detected."
msgstr ""
From a9c73e2846d8120566bed4ee1f1711f94bab20a4 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Fri, 12 Dec 2025 09:27:19 -0800
Subject: [PATCH 08/36] Update validators.py
---
src/paperless/validators.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/paperless/validators.py b/src/paperless/validators.py
index beb87d7b3..36e153881 100644
--- a/src/paperless/validators.py
+++ b/src/paperless/validators.py
@@ -21,6 +21,7 @@ ALLOWED_SVG_TAGS: set[str] = {
"use",
"title",
"desc",
+ "style",
}
ALLOWED_SVG_ATTRIBUTES: set[str] = {
@@ -29,6 +30,7 @@ ALLOWED_SVG_ATTRIBUTES: set[str] = {
"style",
"d",
"fill",
+ "fill-opacity",
"fill-rule",
"stroke",
"stroke-width",
@@ -69,6 +71,7 @@ ALLOWED_SVG_ATTRIBUTES: set[str] = {
"xmlns",
"xmlns:xlink",
"version",
+ "type",
}
From 9ba1d93e1552ae87d8465d40a72c59dba2d9c03d Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Fri, 12 Dec 2025 09:28:17 -0800
Subject: [PATCH 09/36] Merge commit from fork
* Uses a custom transport to resolve the slim chance of a DNS rebinding affecting the webhook
* Fix WebhookTransport hostname resolution and validation
* Fix test failures
* Lint
* Keep all internal logic inside WebhookTransport
* Fix test failure
* Update handlers.py
* Update handlers.py
---------
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
---
src/documents/tests/test_workflows.py | 14 ++-
src/documents/workflows/webhooks.py | 137 ++++++++++++++++++++------
2 files changed, 112 insertions(+), 39 deletions(-)
diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py
index e606bc5a0..249183b6e 100644
--- a/src/documents/tests/test_workflows.py
+++ b/src/documents/tests/test_workflows.py
@@ -17,6 +17,7 @@ from django.utils import timezone
from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms
+from httpx import ConnectError
from httpx import HTTPError
from httpx import HTTPStatusError
from pytest_httpx import HTTPXMock
@@ -3428,7 +3429,7 @@ class TestWorkflows(
expected_str = "Error occurred parsing webhook headers"
self.assertIn(expected_str, cm.output[1])
- @mock.patch("httpx.post")
+ @mock.patch("httpx.Client.post")
def test_workflow_webhook_send_webhook_task(self, mock_post):
mock_post.return_value = mock.Mock(
status_code=200,
@@ -3449,8 +3450,6 @@ class TestWorkflows(
content="Test message",
headers={},
files=None,
- follow_redirects=False,
- timeout=5,
)
expected_str = "Webhook sent to http://paperless-ngx.com"
@@ -3468,11 +3467,9 @@ class TestWorkflows(
data={"message": "Test message"},
headers={},
files=None,
- follow_redirects=False,
- timeout=5,
)
- @mock.patch("httpx.post")
+ @mock.patch("httpx.Client.post")
def test_workflow_webhook_send_webhook_retry(self, mock_http):
mock_http.return_value.raise_for_status = mock.Mock(
side_effect=HTTPStatusError(
@@ -3668,7 +3665,7 @@ class TestWebhookSecurity:
- ValueError is raised
"""
resolve_to("127.0.0.1")
- with pytest.raises(ValueError):
+ with pytest.raises(ConnectError):
send_webhook(
"http://paperless-ngx.com",
data="",
@@ -3698,7 +3695,8 @@ class TestWebhookSecurity:
)
req = httpx_mock.get_request()
- assert req.url.host == "paperless-ngx.com"
+ assert req.url.host == "52.207.186.75"
+ assert req.headers["host"] == "paperless-ngx.com"
def test_follow_redirects_disabled(self, httpx_mock: HTTPXMock, resolve_to):
"""
diff --git a/src/documents/workflows/webhooks.py b/src/documents/workflows/webhooks.py
index c7bb9f7c2..49fb09f6d 100644
--- a/src/documents/workflows/webhooks.py
+++ b/src/documents/workflows/webhooks.py
@@ -10,26 +10,98 @@ from django.conf import settings
logger = logging.getLogger("paperless.workflows.webhooks")
-def _is_public_ip(ip: str) -> bool:
- try:
- obj = ipaddress.ip_address(ip)
- return not (
- obj.is_private
- or obj.is_loopback
- or obj.is_link_local
- or obj.is_multicast
- or obj.is_unspecified
+class WebhookTransport(httpx.HTTPTransport):
+ """
+ Transport that resolves/validates hostnames and rewrites to a vetted IP
+ while keeping Host/SNI as the original hostname.
+ """
+
+ def __init__(
+ self,
+ hostname: str,
+ *args,
+ allow_internal: bool = False,
+ **kwargs,
+ ) -> None:
+ super().__init__(*args, **kwargs)
+ self.hostname = hostname
+ self.allow_internal = allow_internal
+
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
+ hostname = request.url.host
+
+ if not hostname:
+ raise httpx.ConnectError("No hostname in request URL")
+
+ try:
+ addr_info = socket.getaddrinfo(hostname, None)
+ except socket.gaierror as e:
+ raise httpx.ConnectError(f"Could not resolve hostname: {hostname}") from e
+
+ ips = [info[4][0] for info in addr_info if info and info[4]]
+ if not ips:
+ raise httpx.ConnectError(f"Could not resolve hostname: {hostname}")
+
+ if not self.allow_internal:
+ for ip_str in ips:
+ if not WebhookTransport.is_public_ip(ip_str):
+ raise httpx.ConnectError(
+ f"Connection blocked: {hostname} resolves to a non-public address",
+ )
+
+ ip_str = ips[0]
+ formatted_ip = self._format_ip_for_url(ip_str)
+
+ new_headers = httpx.Headers(request.headers)
+ if "host" in new_headers:
+ del new_headers["host"]
+ new_headers["Host"] = hostname
+ new_url = request.url.copy_with(host=formatted_ip)
+
+ request = httpx.Request(
+ method=request.method,
+ url=new_url,
+ headers=new_headers,
+ content=request.content,
+ extensions=request.extensions,
)
- except ValueError: # pragma: no cover
- return False
+ request.extensions["sni_hostname"] = hostname
+ return super().handle_request(request)
-def _resolve_first_ip(host: str) -> str | None:
- try:
- info = socket.getaddrinfo(host, None)
- return info[0][4][0] if info else None
- except Exception: # pragma: no cover
- return None
+ def _format_ip_for_url(self, ip: str) -> str:
+ """
+ Format IP address for use in URL (wrap IPv6 in brackets)
+ """
+ try:
+ ip_obj = ipaddress.ip_address(ip)
+ if ip_obj.version == 6:
+ return f"[{ip}]"
+ return ip
+ except ValueError:
+ return ip
+
+ @staticmethod
+ def is_public_ip(ip: str | int) -> bool:
+ try:
+ obj = ipaddress.ip_address(ip)
+ return not (
+ obj.is_private
+ or obj.is_loopback
+ or obj.is_link_local
+ or obj.is_multicast
+ or obj.is_unspecified
+ )
+ except ValueError: # pragma: no cover
+ return False
+
+ @staticmethod
+ def resolve_first_ip(host: str) -> str | None:
+ try:
+ info = socket.getaddrinfo(host, None)
+ return info[0][4][0] if info else None
+ except Exception: # pragma: no cover
+ return None
@shared_task(
@@ -59,12 +131,10 @@ def send_webhook(
logger.warning("Webhook blocked: port not permitted")
raise ValueError("Destination port not permitted.")
- ip = _resolve_first_ip(p.hostname)
- if not ip or (
- not _is_public_ip(ip) and not settings.WEBHOOKS_ALLOW_INTERNAL_REQUESTS
- ):
- logger.warning("Webhook blocked: destination not allowed")
- raise ValueError("Destination host is not allowed.")
+ transport = WebhookTransport(
+ hostname=p.hostname,
+ allow_internal=settings.WEBHOOKS_ALLOW_INTERNAL_REQUESTS,
+ )
try:
post_args = {
@@ -73,8 +143,6 @@ def send_webhook(
k: v for k, v in (headers or {}).items() if k.lower() != "host"
},
"files": files or None,
- "timeout": 5.0,
- "follow_redirects": False,
}
if as_json:
post_args["json"] = data
@@ -83,14 +151,21 @@ def send_webhook(
else:
post_args["content"] = data
- httpx.post(
- **post_args,
- ).raise_for_status()
- logger.info(
- f"Webhook sent to {url}",
- )
+ with httpx.Client(
+ transport=transport,
+ timeout=5.0,
+ follow_redirects=False,
+ ) as client:
+ client.post(
+ **post_args,
+ ).raise_for_status()
+ logger.info(
+ f"Webhook sent to {url}",
+ )
except Exception as e:
logger.error(
f"Failed attempt sending webhook to {url}: {e}",
)
raise e
+ finally:
+ transport.close()
From 9bdbfd362f4a15f8de109ca959f04e3a7d8a39d0 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Fri, 12 Dec 2025 09:28:47 -0800
Subject: [PATCH 10/36] Merge commit from fork
* Add safe regex matching with timeouts and validation
* Remove redundant length check
* Remove timeouterror workaround
---
pyproject.toml | 1 +
src/documents/matching.py | 30 ++++++++--------
src/documents/regex.py | 50 ++++++++++++++++++++++++++
src/documents/serialisers.py | 7 ++--
src/documents/tests/test_matchables.py | 16 +++++++++
uv.lock | 2 ++
6 files changed, 88 insertions(+), 18 deletions(-)
create mode 100644 src/documents/regex.py
diff --git a/pyproject.toml b/pyproject.toml
index 3108aacd0..60dab9f47 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,6 +63,7 @@ dependencies = [
"pyzbar~=0.1.9",
"rapidfuzz~=3.14.0",
"redis[hiredis]~=5.2.1",
+ "regex>=2025.9.18",
"scikit-learn~=1.7.0",
"setproctitle~=1.3.4",
"tika-client~=0.10.0",
diff --git a/src/documents/matching.py b/src/documents/matching.py
index 2c8d2bf87..198ead64c 100644
--- a/src/documents/matching.py
+++ b/src/documents/matching.py
@@ -20,6 +20,7 @@ from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware
+from documents.regex import safe_regex_search
if TYPE_CHECKING:
from django.db.models import QuerySet
@@ -152,7 +153,7 @@ def match_storage_paths(document: Document, classifier: DocumentClassifier, user
def matches(matching_model: MatchingModel, document: Document):
- search_kwargs = {}
+ search_flags = 0
document_content = document.content
@@ -161,14 +162,18 @@ def matches(matching_model: MatchingModel, document: Document):
return False
if matching_model.is_insensitive:
- search_kwargs = {"flags": re.IGNORECASE}
+ search_flags = re.IGNORECASE
if matching_model.matching_algorithm == MatchingModel.MATCH_NONE:
return False
elif matching_model.matching_algorithm == MatchingModel.MATCH_ALL:
for word in _split_match(matching_model):
- search_result = re.search(rf"\b{word}\b", document_content, **search_kwargs)
+ search_result = re.search(
+ rf"\b{word}\b",
+ document_content,
+ flags=search_flags,
+ )
if not search_result:
return False
log_reason(
@@ -180,7 +185,7 @@ def matches(matching_model: MatchingModel, document: Document):
elif matching_model.matching_algorithm == MatchingModel.MATCH_ANY:
for word in _split_match(matching_model):
- if re.search(rf"\b{word}\b", document_content, **search_kwargs):
+ if re.search(rf"\b{word}\b", document_content, flags=search_flags):
log_reason(matching_model, document, f"it contains this word: {word}")
return True
return False
@@ -190,7 +195,7 @@ def matches(matching_model: MatchingModel, document: Document):
re.search(
rf"\b{re.escape(matching_model.match)}\b",
document_content,
- **search_kwargs,
+ flags=search_flags,
),
)
if result:
@@ -202,16 +207,11 @@ def matches(matching_model: MatchingModel, document: Document):
return result
elif matching_model.matching_algorithm == MatchingModel.MATCH_REGEX:
- try:
- match = re.search(
- re.compile(matching_model.match, **search_kwargs),
- document_content,
- )
- except re.error:
- logger.error(
- f"Error while processing regular expression {matching_model.match}",
- )
- return False
+ match = safe_regex_search(
+ matching_model.match,
+ document_content,
+ flags=search_flags,
+ )
if match:
log_reason(
matching_model,
diff --git a/src/documents/regex.py b/src/documents/regex.py
new file mode 100644
index 000000000..35acc5af0
--- /dev/null
+++ b/src/documents/regex.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import logging
+import textwrap
+
+import regex
+from django.conf import settings
+
+logger = logging.getLogger("paperless.regex")
+
+REGEX_TIMEOUT_SECONDS: float = getattr(settings, "MATCH_REGEX_TIMEOUT_SECONDS", 0.1)
+
+
+def validate_regex_pattern(pattern: str) -> None:
+ """
+ Validate user provided regex for basic compile errors.
+ Raises ValueError on validation failure.
+ """
+
+ try:
+ regex.compile(pattern)
+ except regex.error as exc:
+ raise ValueError(exc.msg) from exc
+
+
+def safe_regex_search(pattern: str, text: str, *, flags: int = 0):
+ """
+ Run a regex search with a timeout. Returns a match object or None.
+ Validation errors and timeouts are logged and treated as no match.
+ """
+
+ try:
+ validate_regex_pattern(pattern)
+ compiled = regex.compile(pattern, flags=flags)
+ except (regex.error, ValueError) as exc:
+ logger.error(
+ "Error while processing regular expression %s: %s",
+ textwrap.shorten(pattern, width=80, placeholder="…"),
+ exc,
+ )
+ return None
+
+ try:
+ return compiled.search(text, timeout=REGEX_TIMEOUT_SECONDS)
+ except TimeoutError:
+ logger.warning(
+ "Regular expression matching timed out for pattern %s",
+ textwrap.shorten(pattern, width=80, placeholder="…"),
+ )
+ return None
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 6265d291c..f4518c04f 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -71,6 +71,7 @@ from documents.parsers import is_mime_type_supported
from documents.permissions import get_document_count_filter_for_user
from documents.permissions import get_groups_with_only_permission
from documents.permissions import set_permissions_for_object
+from documents.regex import validate_regex_pattern
from documents.templating.filepath import validate_filepath_template_and_render
from documents.templating.utils import convert_format_str_to_template_format
from documents.validators import uri_validator
@@ -141,10 +142,10 @@ class MatchingModelSerializer(serializers.ModelSerializer):
and self.initial_data["matching_algorithm"] == MatchingModel.MATCH_REGEX
):
try:
- re.compile(match)
- except re.error as e:
+ validate_regex_pattern(match)
+ except ValueError as e:
raise serializers.ValidationError(
- _("Invalid regular expression: %(error)s") % {"error": str(e.msg)},
+ _("Invalid regular expression: %(error)s") % {"error": str(e)},
)
return match
diff --git a/src/documents/tests/test_matchables.py b/src/documents/tests/test_matchables.py
index 180cf77ed..8b2a7a463 100644
--- a/src/documents/tests/test_matchables.py
+++ b/src/documents/tests/test_matchables.py
@@ -206,6 +206,22 @@ class TestMatching(_TestMatchingBase):
def test_tach_invalid_regex(self):
self._test_matching("[", "MATCH_REGEX", [], ["Don't match this"])
+ def test_match_regex_timeout_returns_false(self):
+ tag = Tag.objects.create(
+ name="slow",
+ match=r"(a+)+$",
+ matching_algorithm=Tag.MATCH_REGEX,
+ )
+ document = Document(content=("a" * 5000) + "X")
+
+ with self.assertLogs("paperless.regex", level="WARNING") as cm:
+ self.assertFalse(matching.matches(tag, document))
+
+ self.assertTrue(
+ any("timed out" in message for message in cm.output),
+ f"Expected timeout log, got {cm.output}",
+ )
+
def test_match_fuzzy(self):
self._test_matching(
"Springfield, Miss.",
diff --git a/uv.lock b/uv.lock
index ff0bb6b5b..69d1f50bb 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2163,6 +2163,7 @@ dependencies = [
{ name = "pyzbar", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "rapidfuzz", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "redis", extra = ["hiredis"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "scikit-learn", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "setproctitle", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "tika-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -2306,6 +2307,7 @@ requires-dist = [
{ name = "pyzbar", specifier = "~=0.1.9" },
{ name = "rapidfuzz", specifier = "~=3.14.0" },
{ name = "redis", extras = ["hiredis"], specifier = "~=5.2.1" },
+ { name = "regex", specifier = ">=2025.9.18" },
{ name = "scikit-learn", specifier = "~=1.7.0" },
{ name = "setproctitle", specifier = "~=1.3.4" },
{ name = "tika-client", specifier = "~=0.10.0" },
From 4d7aa8e1a2db3b393fd87556b15d0a650750cfcf Mon Sep 17 00:00:00 2001
From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 12 Dec 2025 17:30:36 +0000
Subject: [PATCH 11/36] Auto translate strings
---
src/locale/en_US/LC_MESSAGES/django.po | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po
index 1812ebe10..4489784f9 100644
--- a/src/locale/en_US/LC_MESSAGES/django.po
+++ b/src/locale/en_US/LC_MESSAGES/django.po
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-12-12 16:43+0000\n"
+"POT-Creation-Date: 2025-12-12 17:29+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1219,40 +1219,40 @@ msgstr ""
msgid "workflow runs"
msgstr ""
-#: documents/serialisers.py:147
+#: documents/serialisers.py:148
#, python-format
msgid "Invalid regular expression: %(error)s"
msgstr ""
-#: documents/serialisers.py:638
+#: documents/serialisers.py:639
msgid "Invalid color."
msgstr ""
-#: documents/serialisers.py:1824
+#: documents/serialisers.py:1825
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
-#: documents/serialisers.py:1868
+#: documents/serialisers.py:1869
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
-#: documents/serialisers.py:1875
+#: documents/serialisers.py:1876
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
-#: documents/serialisers.py:1892 documents/serialisers.py:1902
+#: documents/serialisers.py:1893 documents/serialisers.py:1903
msgid ""
"Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
-#: documents/serialisers.py:1897
+#: documents/serialisers.py:1898
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
-#: documents/serialisers.py:2012
+#: documents/serialisers.py:2013
msgid "Invalid variable detected."
msgstr ""
From d391fdec64a0933c66f389886ad9fb9b6fc3e93a Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Fri, 12 Dec 2025 09:39:56 -0800
Subject: [PATCH 12/36] Resolve CodeQL warning
---
src/documents/serialisers.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index f4518c04f..5c90c6f1c 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -144,8 +144,9 @@ class MatchingModelSerializer(serializers.ModelSerializer):
try:
validate_regex_pattern(match)
except ValueError as e:
+ logger.debug(f"Invalid regular expression: {e!s}")
raise serializers.ValidationError(
- _("Invalid regular expression: %(error)s") % {"error": str(e)},
+ "Invalid regular expression, see log for details.",
)
return match
From 7130c0bd06467f869d2b669b4c4fbd01dfea38bf Mon Sep 17 00:00:00 2001
From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 12 Dec 2025 17:42:19 +0000
Subject: [PATCH 13/36] Auto translate strings
---
src/locale/en_US/LC_MESSAGES/django.po | 21 ++++++++-------------
1 file changed, 8 insertions(+), 13 deletions(-)
diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po
index 4489784f9..08426267a 100644
--- a/src/locale/en_US/LC_MESSAGES/django.po
+++ b/src/locale/en_US/LC_MESSAGES/django.po
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-12-12 17:29+0000\n"
+"POT-Creation-Date: 2025-12-12 17:41+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1219,40 +1219,35 @@ msgstr ""
msgid "workflow runs"
msgstr ""
-#: documents/serialisers.py:148
-#, python-format
-msgid "Invalid regular expression: %(error)s"
-msgstr ""
-
-#: documents/serialisers.py:639
+#: documents/serialisers.py:640
msgid "Invalid color."
msgstr ""
-#: documents/serialisers.py:1825
+#: documents/serialisers.py:1826
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
-#: documents/serialisers.py:1869
+#: documents/serialisers.py:1870
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
-#: documents/serialisers.py:1876
+#: documents/serialisers.py:1877
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
-#: documents/serialisers.py:1893 documents/serialisers.py:1903
+#: documents/serialisers.py:1894 documents/serialisers.py:1904
msgid ""
"Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
-#: documents/serialisers.py:1898
+#: documents/serialisers.py:1899
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
-#: documents/serialisers.py:2013
+#: documents/serialisers.py:2014
msgid "Invalid variable detected."
msgstr ""
From 6c8a9b037354f2308b28e9df10ec608b5665f911 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 12 Dec 2025 18:12:29 +0000
Subject: [PATCH 14/36] New Crowdin translations by GitHub Action (#11520)
---
src-ui/src/locale/messages.af_ZA.xlf | 72 +-
src-ui/src/locale/messages.ar_AR.xlf | 72 +-
src-ui/src/locale/messages.be_BY.xlf | 72 +-
src-ui/src/locale/messages.bg_BG.xlf | 72 +-
src-ui/src/locale/messages.ca_ES.xlf | 72 +-
src-ui/src/locale/messages.cs_CZ.xlf | 82 +-
src-ui/src/locale/messages.da_DK.xlf | 72 +-
src-ui/src/locale/messages.de_DE.xlf | 136 +-
src-ui/src/locale/messages.el_GR.xlf | 72 +-
src-ui/src/locale/messages.es_ES.xlf | 72 +-
src-ui/src/locale/messages.et_EE.xlf | 72 +-
src-ui/src/locale/messages.fa_IR.xlf | 72 +-
src-ui/src/locale/messages.fi_FI.xlf | 132 +-
src-ui/src/locale/messages.fr_FR.xlf | 82 +-
src-ui/src/locale/messages.he_IL.xlf | 72 +-
src-ui/src/locale/messages.hi_IN.xlf | 11480 +++++++++++++++++++++++
src-ui/src/locale/messages.hr_HR.xlf | 72 +-
src-ui/src/locale/messages.hu_HU.xlf | 86 +-
src-ui/src/locale/messages.id_ID.xlf | 72 +-
src-ui/src/locale/messages.it_IT.xlf | 74 +-
src-ui/src/locale/messages.ja_JP.xlf | 72 +-
src-ui/src/locale/messages.ko_KR.xlf | 72 +-
src-ui/src/locale/messages.lb_LU.xlf | 72 +-
src-ui/src/locale/messages.lt_LT.xlf | 72 +-
src-ui/src/locale/messages.lv_LV.xlf | 72 +-
src-ui/src/locale/messages.mk_MK.xlf | 72 +-
src-ui/src/locale/messages.ms_MY.xlf | 72 +-
src-ui/src/locale/messages.nl_NL.xlf | 120 +-
src-ui/src/locale/messages.no_NO.xlf | 72 +-
src-ui/src/locale/messages.pl_PL.xlf | 72 +-
src-ui/src/locale/messages.pt_BR.xlf | 72 +-
src-ui/src/locale/messages.pt_PT.xlf | 72 +-
src-ui/src/locale/messages.ro_RO.xlf | 72 +-
src-ui/src/locale/messages.ru_RU.xlf | 136 +-
src-ui/src/locale/messages.sk_SK.xlf | 72 +-
src-ui/src/locale/messages.sl_SI.xlf | 72 +-
src-ui/src/locale/messages.sr_CS.xlf | 82 +-
src-ui/src/locale/messages.sv_SE.xlf | 82 +-
src-ui/src/locale/messages.th_TH.xlf | 72 +-
src-ui/src/locale/messages.tr_TR.xlf | 86 +-
src-ui/src/locale/messages.uk_UA.xlf | 72 +-
src-ui/src/locale/messages.vi_VN.xlf | 72 +-
src-ui/src/locale/messages.zh_CN.xlf | 86 +-
src-ui/src/locale/messages.zh_TW.xlf | 72 +-
src/locale/af_ZA/LC_MESSAGES/django.po | 23 +-
src/locale/ar_AR/LC_MESSAGES/django.po | 23 +-
src/locale/be_BY/LC_MESSAGES/django.po | 23 +-
src/locale/bg_BG/LC_MESSAGES/django.po | 23 +-
src/locale/ca_ES/LC_MESSAGES/django.po | 23 +-
src/locale/cs_CZ/LC_MESSAGES/django.po | 23 +-
src/locale/da_DK/LC_MESSAGES/django.po | 23 +-
src/locale/de_DE/LC_MESSAGES/django.po | 29 +-
src/locale/el_GR/LC_MESSAGES/django.po | 23 +-
src/locale/es_ES/LC_MESSAGES/django.po | 23 +-
src/locale/et_EE/LC_MESSAGES/django.po | 23 +-
src/locale/fa_IR/LC_MESSAGES/django.po | 35 +-
src/locale/fi_FI/LC_MESSAGES/django.po | 23 +-
src/locale/fr_FR/LC_MESSAGES/django.po | 25 +-
src/locale/he_IL/LC_MESSAGES/django.po | 23 +-
src/locale/hi_IN/LC_MESSAGES/django.po | 2149 +++++
src/locale/hr_HR/LC_MESSAGES/django.po | 23 +-
src/locale/hu_HU/LC_MESSAGES/django.po | 23 +-
src/locale/id_ID/LC_MESSAGES/django.po | 39 +-
src/locale/it_IT/LC_MESSAGES/django.po | 23 +-
src/locale/ja_JP/LC_MESSAGES/django.po | 23 +-
src/locale/ko_KR/LC_MESSAGES/django.po | 23 +-
src/locale/lb_LU/LC_MESSAGES/django.po | 23 +-
src/locale/lt_LT/LC_MESSAGES/django.po | 23 +-
src/locale/lv_LV/LC_MESSAGES/django.po | 23 +-
src/locale/mk_MK/LC_MESSAGES/django.po | 23 +-
src/locale/ms_MY/LC_MESSAGES/django.po | 23 +-
src/locale/nl_NL/LC_MESSAGES/django.po | 23 +-
src/locale/no_NO/LC_MESSAGES/django.po | 23 +-
src/locale/pl_PL/LC_MESSAGES/django.po | 23 +-
src/locale/pt_BR/LC_MESSAGES/django.po | 23 +-
src/locale/pt_PT/LC_MESSAGES/django.po | 23 +-
src/locale/ro_RO/LC_MESSAGES/django.po | 27 +-
src/locale/ru_RU/LC_MESSAGES/django.po | 23 +-
src/locale/sk_SK/LC_MESSAGES/django.po | 23 +-
src/locale/sl_SI/LC_MESSAGES/django.po | 23 +-
src/locale/sr_CS/LC_MESSAGES/django.po | 23 +-
src/locale/sv_SE/LC_MESSAGES/django.po | 25 +-
src/locale/th_TH/LC_MESSAGES/django.po | 23 +-
src/locale/tr_TR/LC_MESSAGES/django.po | 23 +-
src/locale/uk_UA/LC_MESSAGES/django.po | 23 +-
src/locale/vi_VN/LC_MESSAGES/django.po | 23 +-
src/locale/zh_CN/LC_MESSAGES/django.po | 23 +-
src/locale/zh_TW/LC_MESSAGES/django.po | 23 +-
88 files changed, 15745 insertions(+), 2331 deletions(-)
create mode 100644 src-ui/src/locale/messages.hi_IN.xlf
create mode 100644 src/locale/hi_IN/LC_MESSAGES/django.po
diff --git a/src-ui/src/locale/messages.af_ZA.xlf b/src-ui/src/locale/messages.af_ZA.xlf
index 7219c5dd8..f6523f4e1 100644
--- a/src-ui/src/locale/messages.af_ZA.xlf
+++ b/src-ui/src/locale/messages.af_ZA.xlf
@@ -5,7 +5,7 @@
Close
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/alert/alert.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/alert/alert.ts
50
Sluit
@@ -13,7 +13,7 @@
Slide of
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/carousel/carousel.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/carousel/carousel.ts
131,135
Currently selected slide number read by screen reader
@@ -22,7 +22,7 @@
Previous
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/carousel/carousel.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/carousel/carousel.ts
157,159
Vorige
@@ -30,7 +30,7 @@
Next
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/carousel/carousel.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/carousel/carousel.ts
198
Volgende
@@ -38,11 +38,11 @@
Previous month
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/datepicker/datepicker-navigation.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/datepicker/datepicker-navigation.ts
83,85
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/datepicker/datepicker-navigation.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/datepicker/datepicker-navigation.ts
112
Vorige maand
@@ -50,11 +50,11 @@
Next month
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/datepicker/datepicker-navigation.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/datepicker/datepicker-navigation.ts
112
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/datepicker/datepicker-navigation.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/datepicker/datepicker-navigation.ts
112
Volgende maand
@@ -62,7 +62,7 @@
HH
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts
13
HH
@@ -70,7 +70,7 @@
Close
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts
13
Sluit
@@ -78,11 +78,11 @@
Select month
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts
13
- node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts
+ node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts
13
Kies maand
@@ -90,7 +90,7 @@