Skip to content
17 changes: 17 additions & 0 deletions docs/admin/projects.rst
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,23 @@ might want to strip leading directory by ``parentdir`` filter (see
:ref:`markup`):
``https://github.com/WeblateOrg/hello/blob/{{branch}}/{{filename|parentdir}}#L{{line}}``

.. seealso::

* :setting:`PROJECT_WEB_RESTRICT_PRIVATE`

.. _component-repoweb-translations:

Repository browser for translations
+++++++++++++++++++++++++++++++++++++

URL of repository browser used to display translation files. When empty, the
:ref:`component-repoweb` URL will be used as a fallback. This is useful when
the source files and translation files are hosted in different repositories.
You can use :ref:`markup`.

For example on GitHub, use something like:
``https://github.com/WeblateOrg/translations/blob/{{branch}}/{{filename}}#L{{line}}``
Comment thread
nijel marked this conversation as resolved.
Comment thread
nijel marked this conversation as resolved.

.. seealso::

* :setting:`PROJECT_WEB_RESTRICT_PRIVATE`
Expand Down
3 changes: 3 additions & 0 deletions weblate/trans/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1768,6 +1768,7 @@ class Meta:
"push",
"push_branch",
"repoweb",
"repoweb_translations",
"push_on_commit",
"commit_pending_age",
"merge_style",
Expand Down Expand Up @@ -1868,6 +1869,7 @@ def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
"push",
"push_branch",
"repoweb",
"repoweb_translations",
),
Fieldset(
gettext("Version control settings"),
Expand Down Expand Up @@ -2025,6 +2027,7 @@ class Meta:
"push",
"push_branch",
"repoweb",
"repoweb_translations",
"file_format",
"file_format_params",
"filemask",
Expand Down
27 changes: 27 additions & 0 deletions weblate/trans/migrations/0074_component_repoweb_translations.py
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration is not based on the current main:

Conflicting migrations detected; multiple leaf nodes in the migration graph: (0069_component_repoweb_translations, 0073_alter_change_action in trans).

Please rename it and adjust dependencies.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Copyright © Michal Čihař <michal@weblate.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later

from django.db import migrations, models

import weblate.utils.render


class Migration(migrations.Migration):
dependencies = [
("trans", "0073_alter_change_action"),
]

operations = [
migrations.AddField(
model_name="component",
name="repoweb_translations",
field=models.CharField(
blank=True,
help_text="Link to repository browser for translation files, use {{branch}} for branch, {{filename}} and {{line}} as filename and line placeholders. If left empty, the Repository browser above will be used. You might want to strip leading directory by using {{filename|parentdir}}.",
max_length=200,
validators=[weblate.utils.render.validate_repoweb],
verbose_name="Repository browser for translations",
),
),
]
17 changes: 16 additions & 1 deletion weblate/trans/models/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,18 @@
validators=[validate_repoweb],
blank=True,
)
repoweb_translations = models.CharField(
verbose_name=gettext_lazy("Repository browser for translations"),
max_length=200,
help_text=gettext_lazy(
"Link to repository browser for translation files, use {{branch}} for branch, "
"{{filename}} and {{line}} as filename and line placeholders. "
"If left empty, the Repository browser above will be used. "
"You might want to strip leading directory by using {{filename|parentdir}}."
),
validators=[validate_repoweb],
blank=True,
)
git_export = models.CharField(
verbose_name=gettext_lazy("Exported repository URL"),
max_length=60 + PROJECT_NAME_LENGTH + COMPONENT_NAME_LENGTH,
Expand Down Expand Up @@ -920,7 +932,7 @@
),
)

links = models.ManyToManyField(

Check failure on line 935 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Need type annotation for "links"
"trans.Project",
verbose_name=gettext_lazy("Share in projects"),
blank=True,
Expand Down Expand Up @@ -1445,7 +1457,7 @@
# Calculate progress for translations
if progress is None:
self.translations_progress += 1
progress = 100 * self.translations_progress // self.translations_count

Check failure on line 1460 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Unsupported operand types for // ("int" and "None")
# Store task state
current_task.update_state(
state="PROGRESS", meta={"progress": progress, "component": self.pk}
Expand Down Expand Up @@ -1487,7 +1499,7 @@
if "source_translation" in self.__dict__:
return self.__dict__["source_translation"]
try:
result = self.translation_set.get(language_id=self.source_language_id)

Check failure on line 1502 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible type for lookup 'language_id': (got "int | None", expected "str | int")
except ObjectDoesNotExist:
return None
result.sync_readonly_check_flag()
Expand Down Expand Up @@ -1530,7 +1542,7 @@
)
except IntegrityError:
try:
result = self.translation_set.get(

Check failure on line 1545 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible type for lookup 'language_id': (got "int | None", expected "str | int")
language_id=self.source_language_id
)
except self.translation_set.model.DoesNotExist:
Expand Down Expand Up @@ -1566,7 +1578,7 @@

def _process_new_source(self, source: Unit, *, save: bool = True) -> Change:
# Avoid fetching empty list of checks from the database
source.all_checks = []

Check failure on line 1581 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "list[Never]", variable has type "QuerySet[Check, Check]")
source.source_updated = True
change = source.generate_change(
self.acting_user,
Expand Down Expand Up @@ -1846,6 +1858,7 @@
line: str,
template: str | None = None,
user: User | None = None,
is_translation: bool = False,
):
"""
Generate link to source code browser for given file and line.
Expand All @@ -1854,7 +1867,9 @@
here.
"""
Comment on lines 1863 to 1868
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring for get_repoweb_link now says it generates a link to the “source code browser”, but the new is_translation flag makes it generate translation file links as well. Update the docstring to reflect the broader behavior so callers understand when to use is_translation vs. the default.

Copilot uses AI. Check for mistakes.
if not template:
if self.repoweb:
if is_translation and self.repoweb_translations:
template = self.repoweb_translations
elif self.repoweb:
template = self.repoweb
elif user and user.has_perm("vcs.view", self):
Comment on lines 1869 to 1874
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_translation influences template selection here, but the method later delegates to linked_component.get_repoweb_link(...) without passing is_translation. If template is still falsy at delegation time (e.g., relying on the linked component’s settings), translation links will be generated using the linked component’s non-translation fallback. Ensure the delegation path preserves the is_translation intent so linked repositories can use repoweb_translations correctly.

Copilot uses AI. Check for mistakes.
template = getattr(
Expand Down Expand Up @@ -3215,7 +3230,7 @@

def clear_prefetched_alerts(self) -> None:
with suppress(AttributeError, KeyError):
self._prefetched_objects_cache.pop("alert_set")

Check failure on line 3233 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

"Component" has no attribute "_prefetched_objects_cache"

@property
def lock_alerts(self):
Expand Down Expand Up @@ -3378,7 +3393,7 @@
if self.has_template():
# Avoid parsing if template is invalid
try:
self.template_store.check_valid()

Check failure on line 3396 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Item "None" of "TranslationFormat[Any, Any, Any] | None" has no attribute "check_valid"
except (ValueError, FileParseError) as exc:
raise InvalidTemplateError(info=str(exc)) from exc
self._template_check_done = True
Expand Down Expand Up @@ -3442,7 +3457,7 @@
if (
self.file_format == "po"
and self.new_base.endswith(".pot")
and os.path.exists(self.get_new_base_filename())

Check failure on line 3460 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Argument 1 to "exists" has incompatible type "str | None"; expected "int | str | bytes | PathLike[str] | PathLike[bytes]"
):
# Process pot file as source to include additional metadata
matches = [self.new_base, *matches]
Expand Down Expand Up @@ -3511,7 +3526,7 @@
)
self.handle_parse_error(error.__cause__, filename=self.template)
self.update_import_alerts()
raise error.__cause__ from error # pylint: disable=raising-non-exception

Check failure on line 3529 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Exception must be derived from BaseException
was_change |= bool(translation.reason)
translations[translation.id] = translation
languages[lang.code] = translation
Expand Down Expand Up @@ -3858,7 +3873,7 @@
filename = self.get_new_base_filename()
template = self.has_template()
return self.file_format_cls.is_valid_base_for_new(
filename,

Check failure on line 3876 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Argument 1 to "is_valid_base_for_new" of "TranslationFormat" has incompatible type "str | None"; expected "str"
template,
errors,
fast=fast,
Expand Down
10 changes: 8 additions & 2 deletions weblate/trans/templatetags/translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -912,7 +912,13 @@ def unit_state_title(unit) -> str:


def try_linkify_filename(
text, filename: str, line: str, unit, profile, link_class: str = ""
text,
filename: str,
line: str,
unit,
profile,
link_class: str = "",
is_translation: bool = False,
):
"""
Attempt to convert `text` to a repo link to `filename:line`.
Expand All @@ -925,7 +931,7 @@ def try_linkify_filename(
link = text
elif profile:
link = unit.translation.component.get_repoweb_link(
filename, line, profile.editor_link
filename, line, profile.editor_link, is_translation=is_translation
)
if link:
return format_html(SOURCE_LINK, link, text, link_class)
Expand Down
32 changes: 32 additions & 0 deletions weblate/trans/tests/test_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -2238,3 +2238,35 @@ def test_repo_link_generation_azure(self) -> None:
"https://dev.azure.com/f/c/_git/ATEST/blob/main/test.py#L42",
self.get_url(),
)

def test_translation_repoweb(self):
"""Test that repoweb_translations is used for translation file links."""
self.component.repoweb = (
"https://example.com/source/{{branch}}/f/{{filename}}#_{{line}}"
)
self.component.repoweb_translations = (
"https://example.com/translations/{{branch}}/f/{{filename}}#_{{line}}"
)
self.assertEqual(
"https://example.com/source/main/f/test.py#_42",
self.component.get_repoweb_link("test.py", "42", user=self.user),
)
self.assertEqual(
"https://example.com/translations/main/f/test.po#_1",
self.component.get_repoweb_link(
"test.po", "1", user=self.user, is_translation=True
),
)

def test_translation_repoweb_fallback(self):
"""Test that repoweb is used as fallback when repoweb_translations is blank."""
self.component.repoweb = (
"https://example.com/source/{{branch}}/f/{{filename}}#_{{line}}"
)
self.component.repoweb_translations = ""
self.assertEqual(
"https://example.com/source/main/f/test.po#_1",
self.component.get_repoweb_link(
"test.po", "1", user=self.user, is_translation=True
),
)
30 changes: 15 additions & 15 deletions weblate/trans/views/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext, gettext_lazy, ngettext
from django.utils.translation import gettext, ngettext
from django.views.decorators.http import require_POST

from weblate.checks.models import CHECKS, get_display_checks
Expand Down Expand Up @@ -62,16 +62,16 @@
unit_state_class,
unit_state_title,
)
from weblate.trans.util import redirect_next, render
from weblate.trans.util import redirect_next, render, split_plural
from weblate.utils import messages
from weblate.utils.antispam import is_spam
from weblate.utils.hash import hash_to_checksum
from weblate.utils.html import format_html_join_comma, list_to_tuples
from weblate.utils.lock import WeblateLockTimeoutError
from weblate.utils.messages import get_message_kind
from weblate.utils.ratelimit import revert_rate_limit, session_ratelimit_post
from weblate.utils.state import (
STATE_APPROVED,
STATE_NEEDS_REWRITING,
STATE_TRANSLATED,
)
from weblate.utils.stats import CategoryLanguage, ProjectLanguage
Expand All @@ -84,9 +84,6 @@
)

SESSION_SEARCH_CACHE_TTL = 1800
DELETE_UNIT_LOCKED_MESSAGE = gettext_lazy(
"Could not remove the string because another background operation is in progress. Please try again later."
)


def display_fixups(request: AuthenticatedHttpRequest, fixups: list[str]) -> None:
Expand Down Expand Up @@ -520,11 +517,18 @@
)
return None

if not change.revert(
request.user, change_action=ActionEvents.REVERT, request=request
):
messages.error(request, gettext("Could not revert the selected change."))
if not change.can_revert():
messages.error(request, gettext("Can not revert to empty translation!"))
return None
# Store unit
unit.translate(
request.user,
split_plural(change.old),
STATE_NEEDS_REWRITING
if change.action == ActionEvents.MARKED_EDIT
else unit.state,
change_action=ActionEvents.REVERT,
)
# Redirect to next entry
return HttpResponseRedirect(next_unit_url)

Expand Down Expand Up @@ -797,6 +801,7 @@
"1",
unit,
user.profile,
is_translation=True,
),
},
)
Expand Down Expand Up @@ -866,8 +871,6 @@
threshold=autoform.cleaned_data["threshold"],
)
messages.success(request, result["message"])
for warning in result.get("warnings", []):
messages.warning(request, warning)
else:
task = auto_translate.delay(
translation_id=translation_id,
Expand Down Expand Up @@ -1122,7 +1125,7 @@
else:
try:
created_unit = translation.add_unit(request, **form.as_kwargs())
except WeblateLockTimeoutError:

Check failure on line 1128 in weblate/trans/views/edit.py

View workflow job for this annotation

GitHub Actions / pre-commit

ruff (F821)

weblate/trans/views/edit.py:1128:16: F821 Undefined name `WeblateLockTimeoutError`

Check failure on line 1128 in weblate/trans/views/edit.py

View workflow job for this annotation

GitHub Actions / pylint

E0602

Undefined variable 'WeblateLockTimeoutError'
messages.error(
request,
gettext(
Expand Down Expand Up @@ -1153,9 +1156,6 @@

try:
unit.translation.delete_unit(request, unit)
except WeblateLockTimeoutError:
messages.error(request, DELETE_UNIT_LOCKED_MESSAGE)
return redirect(unit)
except FileParseError as error:
unit.translation.component.update_import_alerts(delete=False)
messages.error(request, gettext("Could not remove the string: %s") % error)
Expand Down
Loading