Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Weblate 2026.11

.. rubric:: Improvements

* :doc:`Kotlin SDK integration <devel/kotlin-sdk>` now offers Translation files CDN installation and Gradle configuration for original XML downloads.

* :ref:`fedora-messaging` now publishes using Kombu, removing the Twisted dependency. Broker URLs no longer accept query strings or fragments; configure delivery timing using the add-on fields.
* Sped up source-string linking during :ref:`project backup restoration <projectbackup>`.
* Sped up :ref:`Git export URL updates <git-export>` for projects with categorized components.
Expand Down
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ def setup(app: Sphinx) -> None:
("py:class", "Form"),
# Autodoc renders these type annotations from wlc signatures, but their
# reference targets are not available in the configured inventories.
("py:class", "HttpRequest"),
("py:class", "Path"),
("py:class", "JSONValue"),
("py:class", "RequestParams"),
Expand Down
26 changes: 22 additions & 4 deletions docs/devel/kotlin-sdk.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ Kotlin SDK for Android
The official Kotlin SDK for Weblate delivers translation updates to Android
applications without rebuilding or redistributing them. Its Gradle plugin
generates and uploads resource metadata for each build. The Android library
downloads updated translations from the public CDN configured by the
:ref:`addon-weblate.cdn.kotlin` add-on.
downloads updated translations from the public CDNs configured by the
:ref:`addon-weblate.cdn.kotlin` and :ref:`addon-weblate.cdn.files` add-ons.
The latter supplies original Android XML translation files.

The SDK is maintained in the `Kotlin SDK repository
<https://github.com/WeblateOrg/kotlin-sdk/>`_. The plugin is published on the
Expand Down Expand Up @@ -39,8 +40,18 @@ string resources. For self-hosted Weblate, configure :setting:`LOCALIZE_CDN_URL`
and :setting:`LOCALIZE_CDN_PATH` first. See :ref:`addon-weblate.cdn.kotlin` for
public CDN publication, supported resources, and build retention settings.

The installation option :guilabel:`Install Translation files CDN` is enabled
by default. It installs :ref:`addon-weblate.cdn.files` to publish the original
Android XML files for the SDK. An existing installation of that add-on is reused.
Both CDNs publish publicly, including translations from private components.

For an existing Kotlin SDK installation without Translation files CDN, use
:guilabel:`Install Translation files CDN` on the Kotlin SDK add-on configuration
page, or install it from the component's :guilabel:`Add-ons` tab. Return to the
Kotlin SDK configuration page afterwards to copy the updated Gradle snippet.

Copy the Gradle configuration from the add-on configuration page. It supplies
the server URL, CDN URL, project slug, and component path.
the server URL, both CDN URLs, project slug, and component path.
Create a :ref:`project-scoped API token <project-api>` with permission to edit
the component. Set it as ``WEBLATE_API_TOKEN`` in the build environment used
to upload metadata. Keep the token out of source control and the application.
Expand Down Expand Up @@ -68,10 +79,17 @@ slugs below with your component's values. Read the token from the environment:

weblate {
serverUrl = "https://hosted.weblate.org"
cdnUrl = "https://weblate-cdn.com/ADDON_ID"
project = "my-project"
component = "android"
authToken.set(providers.environmentVariable("WEBLATE_API_TOKEN"))

translationFiles {
cdnUrl = "https://weblate-cdn.com/TRANSLATION_FILES_ADDON_ID"
}

kotlinSdk {
cdnUrl = "https://weblate-cdn.com/KOTLIN_SDK_ADDON_ID"
}
}

The token is needed only when uploading build metadata. The application
Expand Down
10 changes: 9 additions & 1 deletion docs/snippets/addons-autogenerated.rst
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,16 @@ names and their submitted IDs alongside translated text. Do not treat the
uploaded resource mapping as confidential; enable publication only when this
disclosure is intended. Registration and status API access remains permission-controlled.

Installation offers to install :ref:`addon-weblate.cdn.files` by default so the
SDK can also consume the original Android XML files. Existing Translation files
CDN installations are reused. For existing Kotlin SDK installations, use
:guilabel:`Install Translation files CDN` on the configuration page, then return
to copy the updated Gradle configuration. Original files are also published
publicly, including those from private components.

Copy the Gradle plugin configuration from the add-on configuration page. It
includes the Weblate server URL, public CDN URL, project slug, and component
includes the Weblate server URL, separate ``translationFiles`` and ``kotlinSdk``
public CDN URLs, project slug, and component
path (with any category path URL-encoded). Replace the placeholder with a
project-scoped API token whose permissions allow editing the component. Project
administrators can create one in the project's :guilabel:`API access` tab; see
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2205,6 +2205,10 @@ sort_inline_tables = true
sort_table_keys = true
spaces_before_inline_comment = 2

[tool.tomlsort.overrides."tool.pytest.ini_options.filterwarnings"]
# Pytest applies the last matching filter, so preserve the warning precedence.
inline_arrays = false

[tool.typos.default]
extend-ignore-re = [
"(?Rm)^.*# codespell:ignore.*$",
Expand Down Expand Up @@ -2265,7 +2269,3 @@ extend-exclude = [
"weblate/trans/tests/data",
"weblate/utils/licensedata.py"
]

[tool.tomlsort.overrides."tool.pytest.ini_options.filterwarnings"]
# Pytest applies the last matching filter, so preserve the warning precedence.
inline_arrays = false
5 changes: 4 additions & 1 deletion weblate/addons/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

from django.forms import Form
from django.forms.boundfield import BoundField
from django.http import HttpRequest
from django.urls.resolvers import URLPattern
from django_stubs_ext import StrOrPromise

Expand Down Expand Up @@ -220,7 +221,9 @@
def has_settings(cls) -> bool:
return cls.settings_form is not None

def get_integration_instructions(self, user: User) -> str:
def get_integration_instructions(
self, user: User, request: HttpRequest | None = None
) -> str:
"""Render integration instructions independently of the settings form."""
return ""

Expand Down Expand Up @@ -803,7 +806,7 @@
for outcome in outcomes
if outcome.status == status
)
result: dict | None = None

Check failure on line 809 in weblate/addons/base.py

View workflow job for this annotation

GitHub Actions / mypy

Name "result" already defined on line 782
if component is not None and len(results) == 1:
result = next(iter(results.values()))
elif results:
Expand Down
10 changes: 8 additions & 2 deletions weblate/addons/cdn.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
CDN_MANIFEST_FILENAME = "manifest.json"

if TYPE_CHECKING:
from django.http import HttpRequest

from weblate.addons.models import Addon
from weblate.auth.models import User
from weblate.trans.models import Category, Component, Project, Translation
Expand Down Expand Up @@ -195,7 +197,9 @@ class CDNJSAddon(CDNBaseAddon):

settings_form = CDNJSForm

def get_integration_instructions(self, user: User) -> str:
def get_integration_instructions(
self, user: User, request: HttpRequest | None = None
) -> str:
if not self.instance.pk:
return ""
return render_to_string(
Expand Down Expand Up @@ -324,7 +328,9 @@ class CDNFilesAddon(CDNBaseAddon):
)
needs_component = True

def get_integration_instructions(self, user: User) -> str:
def get_integration_instructions(
self, user: User, request: HttpRequest | None = None
) -> str:
if not self.instance.pk:
return ""
return render_to_string(
Expand Down
5 changes: 4 additions & 1 deletion weblate/addons/fedora_messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
from typing import Any

from django.forms.boundfield import BoundField
from django.http import HttpRequest
from django_stubs_ext import StrOrPromise

from weblate.addons.models import AddonActivityLog
Expand Down Expand Up @@ -292,7 +293,9 @@ class FedoraMessagingAddon(ChangeBaseAddon):
multiple = False
version_added = "5.15"

def get_integration_instructions(self, user: User) -> str:
def get_integration_instructions(
self, user: User, request: HttpRequest | None = None
) -> str:
return render_to_string(
"addons/fedora_messaging_integration.html", {"addon": self, "user": user}
)
Expand Down
6 changes: 4 additions & 2 deletions weblate/addons/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ def post(self, request: AuthenticatedHttpRequest, **kwargs):
"form": form,
"object": self.path_object,
"integration_instructions": integration_addon.get_integration_instructions(
request.user
request.user, request=request
),
},
)
Expand Down Expand Up @@ -448,7 +448,9 @@ def get_context_data(self, **kwargs):
result = super().get_context_data(**kwargs)
if self.object.is_valid:
result["integration_instructions"] = (
self.object.addon.get_integration_instructions(self.request.user)
self.object.addon.get_integration_instructions(
self.request.user, request=self.request
)
)
execution_form = self.object.addon.get_execution_form()
if execution_form is not None:
Expand Down
10 changes: 8 additions & 2 deletions weblate/addons/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
from collections.abc import Mapping
from datetime import datetime

from django.http import HttpRequest

from weblate.addons.models import AddonActivityLog
from weblate.auth.models import User
from weblate.trans.models import Change
Expand Down Expand Up @@ -178,7 +180,9 @@ class WebhookAddon(JSONWebhookBaseAddon):

settings_form = WebhooksAddonForm

def get_integration_instructions(self, user: User) -> str:
def get_integration_instructions(
self, user: User, request: HttpRequest | None = None
) -> str:
return render_to_string(
"addons/webhook_integration.html", {"addon": self, "user": user}
)
Expand Down Expand Up @@ -291,7 +295,9 @@ class SlackWebhookAddon(JSONWebhookBaseAddon):
settings_form = BaseWebhooksAddonForm
version_added = "5.12"

def get_integration_instructions(self, user: User) -> str:
def get_integration_instructions(
self, user: User, request: HttpRequest | None = None
) -> str:
return render_to_string(
"addons/slack_integration.html", {"addon": self, "user": user}
)
Expand Down
79 changes: 74 additions & 5 deletions weblate/kotlin_sdk/addons.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,23 @@
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Field, Layout
from django import forms
from django.core.exceptions import ValidationError
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy

from weblate.addons.cdn import CDNBaseAddon
from weblate.addons.cdn import CDNBaseAddon, CDNFilesAddon
from weblate.addons.events import AddonEvent
from weblate.addons.forms import BaseAddonForm
from weblate.kotlin_sdk.api import get_api_urls
from weblate.kotlin_sdk.publication import Publication
from weblate.utils.change_origin import get_acting_user
from weblate.utils.site import get_site_url

if TYPE_CHECKING:
from django.http import HttpRequest
from django.urls.resolvers import URLPattern

from weblate.addons.models import Addon
from weblate.auth.models import User
from weblate.trans.models import Component, Translation

Expand Down Expand Up @@ -51,10 +55,30 @@ class KotlinSDKForm(BaseAddonForm):
),
)

install_translation_files = forms.BooleanField(
label=gettext_lazy("Install Translation files CDN"),
required=False,
initial=True,
help_text=gettext_lazy(
"Publishes original translation files publicly, including files from private components."
),
)

def __init__(self, *args, **kwargs) -> None: # ruff: ignore[missing-type-args, missing-type-kwargs]
super().__init__(*args, **kwargs)
self.helper = FormHelper(self)
self.helper.layout = Layout(Field("maximum_age"), Field("maximum_versions"))
if self._addon.instance.pk is None and not self._addon.documentation_build:
self.helper.layout.append(Field("install_translation_files"))
else:
del self.fields["install_translation_files"]

def serialize_form(self) -> dict[str, int | bool]:
data = dict(super().serialize_form())
install = data.pop("install_translation_files", False)
if self._addon.instance.pk is None and install:
data["_install_translation_files"] = True
return data


class KotlinSDKAddon(CDNBaseAddon):
Expand All @@ -76,23 +100,68 @@ class KotlinSDKAddon(CDNBaseAddon):
AddonEvent.EVENT_DAILY,
}

def get_integration_instructions(self, user: User) -> str:
def get_translation_files_addon(self) -> Addon | None:
from weblate.addons.models import Addon # ruff: ignore[import-outside-top-level]

return Addon.objects.filter(
component=self.instance.component, name=CDNFilesAddon.name
).first()

def post_configure(self, run: bool = True) -> None:
if self.instance.configuration.pop("_install_translation_files", False):
component = self.instance.component
if component is not None and self.get_translation_files_addon() is None:
if not CDNFilesAddon.can_install(component=component):
raise ValidationError(
gettext_lazy(
"Translation files CDN cannot be installed on this component."
)
)
CDNFilesAddon.create(
component=component, acting_user=get_acting_user(), run=run
)
component.drop_addons_cache()
self.instance.save(update_fields=["configuration"])
super().post_configure(run=run)

def get_integration_instructions(
self, user: User, request: HttpRequest | None = None
) -> str:
component = self.instance.component
if not self.instance.pk or component is None:
return ""
component_path = "%2F".join(component.get_url_path()[1:])
files_addon = self.get_translation_files_addon()
snippet = (
"weblate {\n"
f' serverUrl = "{get_site_url()}"\n'
f' cdnUrl = "{self.cdn_base_url}"\n'
' authToken = "INSERT_TOKEN_HERE"\n'
f' project = "{component.project.slug}"\n'
f' component = "{component_path}"\n'
"}"
)
if files_addon is not None:
snippet += (
"\n translationFiles {\n"
f' cdnUrl = "{CDNFilesAddon(files_addon).cdn_base_url}"\n'
" }\n"
)
snippet += (
f'\n kotlinSdk {{\n cdnUrl = "{self.cdn_base_url}"\n }}\n}}'
)
return render_to_string(
"addons/kotlin.html",
{"snippet": snippet, "project": component.project, "user": user},
{
"snippet": snippet,
"project": component.project,
"user": user,
"component": component,
"files_addon_missing": files_addon is None,
"can_install_files": request is not None
and files_addon is None
and user.has_perm("component.edit", component)
and CDNFilesAddon.can_install(component=component),
},
request=request,
)

@classmethod
Expand Down
Loading
Loading