diff --git a/docs/changes.rst b/docs/changes.rst index 955a38a2e32c..a4d3eb11d896 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,7 @@ Weblate 2026.7 .. rubric:: New features +* Added the :ref:`check-accelerators` quality check, which verifies that accelerator keys are used consistently between the source and the translation. Enable it with the ``accelerators`` flag. * Added :ref:`mt-mistral` machinery integration for Mistral LLM automatic suggestions. .. rubric:: Improvements diff --git a/docs/snippets/check-flags-autogenerated.rst b/docs/snippets/check-flags-autogenerated.rst index 4d60bdb64599..d18c2e38ba4c 100644 --- a/docs/snippets/check-flags-autogenerated.rst +++ b/docs/snippets/check-flags-autogenerated.rst @@ -47,6 +47,8 @@ .. AUTOGENERATED START: check-flags-enables .. This section is automatically generated by `./manage.py list_checks`. Do not edit manually. +``accelerators`` + Enables the :ref:`check-accelerators` quality check. ``bbcode-text`` Treat a text as an Bulletin Board Code (BBCode) document, affects :ref:`check-same`. Enables the :ref:`check-bbcode` quality check. @@ -150,6 +152,8 @@ .. AUTOGENERATED START: check-flags-ignores .. This section is automatically generated by `./manage.py list_checks`. Do not edit manually. +``ignore-accelerators`` + Skip the :ref:`check-accelerators` quality check. ``ignore-bbcode`` Skip the :ref:`check-bbcode` quality check. ``ignore-xml-chars-around-tags`` diff --git a/docs/snippets/checks-autogenerated.rst b/docs/snippets/checks-autogenerated.rst index 051650d62041..5fac9c7b8ed5 100644 --- a/docs/snippets/checks-autogenerated.rst +++ b/docs/snippets/checks-autogenerated.rst @@ -4,6 +4,40 @@ Translation checks Executed upon every translation change, helping translators maintain good quality translations. +.. AUTOGENERATED START: check-accelerators +.. This section is automatically generated by `./manage.py list_checks`. Do not edit manually. + +.. _check-accelerators: + +Accelerator key +~~~~~~~~~~~~~~~ + +.. versionadded:: 2026.7 + +:Summary: Source and translation contain inconsistent accelerator keys. +:Scope: translated strings +:Check class: ``weblate.checks.chars.AcceleratorKeyCheck`` +:Check identifier: ``accelerators`` +:Trigger: This check needs to be enabled using a flag. +:Flag to enable: ``accelerators`` +:Flag to ignore: ``ignore-accelerators`` + +.. AUTOGENERATED END: check-accelerators + +Accelerator keys (also known as mnemonics) let users trigger a UI action from +the keyboard. They are marked in the string by a single character, typically +``&`` (Qt, Windows) or ``_`` (GTK). Literal marker characters can usually be +escaped (for example ``&&`` in Qt/Windows and ``__`` in GTK) and are ignored by +this check, as are HTML entities such as ``&``. This check verifies that the +source and the translation contain the same number of accelerator keys, and that +the translation does not contain more than one. + +.. note:: + + A string such as ``Walter & Sons`` might be translated without the + ampersand and trigger a false positive. Use the ``ignore-accelerators`` + flag to skip the check for such strings. + .. AUTOGENERATED START: check-bbcode .. This section is automatically generated by `./manage.py list_checks`. Do not edit manually. diff --git a/weblate/checks/chars.py b/weblate/checks/chars.py index 0629c34ffafd..421cdc9f0c8e 100644 --- a/weblate/checks/chars.py +++ b/weblate/checks/chars.py @@ -41,6 +41,39 @@ INTERROBANGS = ("?!", "!?", "؟!", "!؟", "?!", "!?", "⁈", "⁉") +class AcceleratorKeyCheck(TargetCheck): + """Check for inconsistent accelerator keys.""" + + check_id = "accelerators" + name = gettext_lazy("Accelerator key") + description = gettext_lazy( + "Source and translation contain inconsistent accelerator keys." + ) + default_disabled = True + version_added = "2026.7" + + def _count_accelerators(self, text: str, key: str) -> int: + # HTML/XML entities start with '&' but are not accelerator markers. + if key == "&": + text = strip_entities(text) + # Qt/Windows escape a literal ampersand as "&&". + text = text.replace("&&", "") + elif key == "_": + # GTK escapes a literal underscore as "__". + text = text.replace("__", "") + return text.count(key) + + def _check_accelerator(self, source: str, target: str, key: str) -> bool: + src_count = self._count_accelerators(source, key) + tgt_count = self._count_accelerators(target, key) + return tgt_count > 1 or src_count != tgt_count + + def check_single(self, source: str, target: str, unit: Unit) -> bool: + return self._check_accelerator(source, target, "&") or self._check_accelerator( + source, target, "_" + ) + + class BeginNewlineCheck(TargetCheck): """Check for newlines at beginning.""" diff --git a/weblate/checks/defaults.py b/weblate/checks/defaults.py index ab5352c5ae4f..ca7c946c205c 100644 --- a/weblate/checks/defaults.py +++ b/weblate/checks/defaults.py @@ -6,6 +6,7 @@ DEFAULT_CHECK_LIST: tuple[str, ...] = ( "weblate.checks.same.SameCheck", + "weblate.checks.chars.AcceleratorKeyCheck", "weblate.checks.chars.BeginNewlineCheck", "weblate.checks.chars.EndNewlineCheck", "weblate.checks.chars.BeginSpaceCheck", diff --git a/weblate/checks/tests/test_chars_checks.py b/weblate/checks/tests/test_chars_checks.py index 6508ca6f392a..8c87d25edd79 100644 --- a/weblate/checks/tests/test_chars_checks.py +++ b/weblate/checks/tests/test_chars_checks.py @@ -7,6 +7,7 @@ from django.test import SimpleTestCase from weblate.checks.chars import ( + AcceleratorKeyCheck, BeginNewlineCheck, BeginSpaceCheck, DoubleSpaceCheck, @@ -32,6 +33,39 @@ from weblate.trans.tests.factories import make_check, make_unit +class AcceleratorKeyCheckTest(CheckTestCase): + check = AcceleratorKeyCheck() + + def setUp(self) -> None: + super().setUp() + self.test_good_matching = ("&File", "&File", "accelerators") + self.test_good_none = ("File", "File", "accelerators") + self.test_good_flag = ("&File", "File", "") + self.test_failure_1 = ("&File", "File", "accelerators") + self.test_failure_2 = ("File", "&File", "accelerators") + self.test_failure_3 = ("&File", "&File &Edit", "accelerators") + + def test_underscore_accelerator(self) -> None: + self.do_test(False, ("_File", "_File", "accelerators")) + self.do_test(True, ("_File", "File", "accelerators")) + self.do_test(True, ("File", "_File", "accelerators")) + + def test_literal_ampersand(self) -> None: + # A literal ampersand present in both source and translation should not trigger this check. + self.do_test(False, ("Walter & Sons", "Walter & Sons", "accelerators")) + # Escaped/literal ampersands (Qt/Windows "&&") should not count as accelerators. + self.do_test(False, ("Save && Exit", "Save && Exit", "accelerators")) + # HTML entities should not be treated as accelerators. + self.do_test(False, ("Fish & Chips", "Fish & Chips", "accelerators")) + self.do_test(False, ("A & B & C", "A & B & C", "accelerators")) + + def test_escaped_underscore(self) -> None: + # Escaped/literal underscores (GTK "__") should not count as accelerators. + self.do_test(False, ("__File", "__File", "accelerators")) + # "___" is commonly used for a literal underscore plus an accelerator marker. + self.do_test(False, ("___File", "___File", "accelerators")) + + class BeginNewlineCheckTest(CheckTestCase): check = BeginNewlineCheck() diff --git a/weblate/settings_example.py b/weblate/settings_example.py index 2f77f296f77f..805c49d630e0 100644 --- a/weblate/settings_example.py +++ b/weblate/settings_example.py @@ -728,6 +728,7 @@ # List of quality checks # CHECK_LIST = ( # "weblate.checks.same.SameCheck", +# "weblate.checks.chars.AcceleratorKeyCheck", # "weblate.checks.chars.BeginNewlineCheck", # "weblate.checks.chars.EndNewlineCheck", # "weblate.checks.chars.BeginSpaceCheck",