diff --git a/docs/admin/projects.rst b/docs/admin/projects.rst index ca891f294372..b17fd6e145b8 100644 --- a/docs/admin/projects.rst +++ b/docs/admin/projects.rst @@ -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}}`` + .. seealso:: * :setting:`PROJECT_WEB_RESTRICT_PRIVATE` diff --git a/weblate/trans/forms.py b/weblate/trans/forms.py index 125ab7dccb0b..4b20a18f1380 100644 --- a/weblate/trans/forms.py +++ b/weblate/trans/forms.py @@ -1768,6 +1768,7 @@ class Meta: "push", "push_branch", "repoweb", + "repoweb_translations", "push_on_commit", "commit_pending_age", "merge_style", @@ -1868,6 +1869,7 @@ def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: "push", "push_branch", "repoweb", + "repoweb_translations", ), Fieldset( gettext("Version control settings"), @@ -2025,6 +2027,7 @@ class Meta: "push", "push_branch", "repoweb", + "repoweb_translations", "file_format", "file_format_params", "filemask", diff --git a/weblate/trans/migrations/0074_component_repoweb_translations.py b/weblate/trans/migrations/0074_component_repoweb_translations.py new file mode 100644 index 000000000000..584eeb208862 --- /dev/null +++ b/weblate/trans/migrations/0074_component_repoweb_translations.py @@ -0,0 +1,27 @@ +# Copyright © Michal Čihař +# +# 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", + ), + ), + ] diff --git a/weblate/trans/models/component.py b/weblate/trans/models/component.py index 98d9a149b047..2aedc55269e0 100644 --- a/weblate/trans/models/component.py +++ b/weblate/trans/models/component.py @@ -560,6 +560,18 @@ class Component( # noqa: PLR0904 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, @@ -1846,6 +1858,7 @@ def get_repoweb_link( 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. @@ -1854,7 +1867,9 @@ def get_repoweb_link( here. """ 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): template = getattr( diff --git a/weblate/trans/templatetags/translations.py b/weblate/trans/templatetags/translations.py index ad5dfd4ee3ac..9bdb982bd251 100644 --- a/weblate/trans/templatetags/translations.py +++ b/weblate/trans/templatetags/translations.py @@ -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`. @@ -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) diff --git a/weblate/trans/tests/test_component.py b/weblate/trans/tests/test_component.py index 9fe00829226a..86e2965cfbf9 100644 --- a/weblate/trans/tests/test_component.py +++ b/weblate/trans/tests/test_component.py @@ -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 + ), + ) diff --git a/weblate/trans/views/edit.py b/weblate/trans/views/edit.py index 26fb1b70f2b4..346eb468f248 100644 --- a/weblate/trans/views/edit.py +++ b/weblate/trans/views/edit.py @@ -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 @@ -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 @@ -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: @@ -520,11 +517,18 @@ def handle_revert(unit, request: AuthenticatedHttpRequest, next_unit_url): ) 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) @@ -797,6 +801,7 @@ def translate(request: AuthenticatedHttpRequest, path): "1", unit, user.profile, + is_translation=True, ), }, ) @@ -866,8 +871,6 @@ def auto_translation(request: AuthenticatedHttpRequest, path): 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, @@ -1153,9 +1156,6 @@ def delete_unit(request: AuthenticatedHttpRequest, unit_id): 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)