From d3dc565cd2f1c3d2fe3b4f903c1a1bb797e8addd Mon Sep 17 00:00:00 2001 From: Karen Konou Date: Wed, 20 May 2026 10:50:07 +0200 Subject: [PATCH 01/14] feat(client): improve flag selection interface --- docs/changes.rst | 2 + weblate/checks/flags.py | 93 +++++++++++++ weblate/checks/tests/test_flags.py | 23 ++++ weblate/static/js/flag-editor.js | 197 +++++++++++++++++++++++++++ weblate/templates/base.html | 3 + weblate/trans/forms.py | 23 +++- weblate/trans/tests/test_js_views.py | 11 ++ weblate/trans/views/js.py | 8 +- weblate/urls.py | 5 + 9 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 weblate/static/js/flag-editor.js diff --git a/docs/changes.rst b/docs/changes.rst index c65afb83dfec..b4c8fd1a5fd5 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,6 +10,8 @@ Weblate 2026.6 * Docker containers can now adjust :setting:`WEBLATE_FORMATS`. Use :envvar:`WEBLATE_ADD_FORMATS` and :envvar:`WEBLATE_REMOVE_FORMATS`. * Improved performance of the :ref:`check-inconsistent` check on large projects. +* Translation flag fields now use a tag-based editor with autocompletion + and grouped suggestions for all known flags. .. rubric:: Bug fixes diff --git a/weblate/checks/flags.py b/weblate/checks/flags.py index 06c5d197add8..718d66fb0fc1 100644 --- a/weblate/checks/flags.py +++ b/weblate/checks/flags.py @@ -452,3 +452,96 @@ class FlagsValidator(Flags): FlagInput = str | etree._Element | Flags | FlagItems | None # noqa: SLF001 + + +# Categorization of flags for the UI flag editor. Keys are flag names that +# are not derived from a check; values are translatable category labels. +_FLAG_CATEGORIES: dict[str, StrOrPromise] = { + "rst-text": gettext_lazy("Text format"), + "md-text": gettext_lazy("Text format"), + "xml-text": gettext_lazy("Text format"), + "url": gettext_lazy("Text format"), + "read-only": gettext_lazy("String behavior"), + "forbidden": gettext_lazy("String behavior"), + "terminology": gettext_lazy("String behavior"), + "case-insensitive": gettext_lazy("String behavior"), + "strict-same": gettext_lazy("String behavior"), + "strict-format": gettext_lazy("String behavior"), + "ignore-all-checks": gettext_lazy("String behavior"), + "priority": gettext_lazy("String behavior"), + "max-length": gettext_lazy("String behavior"), + "replacements": gettext_lazy("String behavior"), + "variant": gettext_lazy("String behavior"), + "font-family": gettext_lazy("Rendering"), + "font-size": gettext_lazy("Rendering"), + "font-weight": gettext_lazy("Rendering"), + "font-spacing": gettext_lazy("Rendering"), + "icu-flags": gettext_lazy("ICU MessageFormat"), + "icu-tag-prefix": gettext_lazy("ICU MessageFormat"), + "fluent-type": gettext_lazy("Format"), + DISCARD_FLAG: gettext_lazy("Other"), +} + + +def _flag_choice( + name: str, + label: StrOrPromise, + *, + category: StrOrPromise, + has_value: bool, +) -> dict[str, str | bool]: + return { + "name": name, + "label": str(label), + "category": str(category), + "has_value": has_value, + } + + +@lru_cache(maxsize=1) +def get_flag_choices() -> tuple[dict[str, str | bool], ...]: + """Return catalog of all known flags for the UI flag editor. + + Each entry contains: + - ``name``: the actual flag identifier as used in the flag string + - ``label``: human-readable description + - ``category``: localized category for grouping in the UI + - ``has_value``: ``True`` when the flag requires a colon-separated value + """ + choices: list[dict[str, str | bool]] = [] + seen: set[str] = set() + enable_strings = {check.enable_string for check in CHECKS.values()} + + def add(name: str, label: StrOrPromise, category: StrOrPromise, has_value: bool) -> None: # noqa: FBT001 + if name in seen: + return + seen.add(name) + choices.append( + _flag_choice(name, label, category=category, has_value=has_value) + ) + + for name, label in PLAIN_FLAGS.items(): + category = _FLAG_CATEGORIES.get( + name, + gettext_lazy("Enabled check") + if name in enable_strings + else gettext_lazy("Automatic detection"), + ) + add(name, label, category, has_value=False) + + for name, label in TYPED_FLAGS.items(): + category = _FLAG_CATEGORIES.get( + name, + gettext_lazy("Parametrized check") + if name in enable_strings + else gettext_lazy("Other"), + ) + add(name, label, category, has_value=True) + + ignore_category = gettext_lazy("Ignored check") + for check in CHECKS.values(): + add(check.ignore_string, check.name, ignore_category, has_value=False) + for ignore_string in AUTOFIXES.get_ignore_strings(): + add(ignore_string, ignore_string, ignore_category, has_value=False) + + return tuple(choices) diff --git a/weblate/checks/tests/test_flags.py b/weblate/checks/tests/test_flags.py index 369bd1e499d7..44e54e2ea529 100644 --- a/weblate/checks/tests/test_flags.py +++ b/weblate/checks/tests/test_flags.py @@ -12,6 +12,7 @@ Flags, FlagsValidator, get_auto_flag_names, + get_flag_choices, ) from weblate.formats.helpers import NamedBytesIO from weblate.formats.ttkit import PoFormat @@ -333,3 +334,25 @@ def check_location_flags(content: str, expected_flags: set[str]) -> None: # test md-text flag for MDX content = f'{PO_HEADER}#: ../../path/file.mdx:24 ../../path/file.mdx:52msgid "Hello, world!"msgstr "Nazdar svete!"' check_location_flags(content, {"md-text"}) + + def test_get_flag_choices(self) -> None: + choices = get_flag_choices() + # Catalog is non-empty and every entry has the expected keys + self.assertGreater(len(choices), 0) + for entry in choices: + self.assertIn("name", entry) + self.assertIn("label", entry) + self.assertIn("category", entry) + self.assertIn("has_value", entry) + names = {entry["name"] for entry in choices} + # A few representative flags from each category are exposed + self.assertIn("read-only", names) + self.assertIn("max-length", names) + self.assertIn("md-text", names) + # Typed flags are marked as such + max_length = next(e for e in choices if e["name"] == "max-length") + self.assertTrue(max_length["has_value"]) + read_only = next(e for e in choices if e["name"] == "read-only") + self.assertFalse(read_only["has_value"]) + # Names are unique (no duplicates across categories) + self.assertEqual(len(names), len(choices)) diff --git a/weblate/static/js/flag-editor.js b/weblate/static/js/flag-editor.js new file mode 100644 index 000000000000..63fed76891ff --- /dev/null +++ b/weblate/static/js/flag-editor.js @@ -0,0 +1,197 @@ +// Copyright © Michal Čihař +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Tag-based editor for translation flag fields, powered by tom-select. +// Activates on any input/textarea carrying the "flag-editor" class and a +// "data-flag-choices-url" attribute pointing at the JSON flag catalog. + +(() => { + let flagChoicesPromise = null; + + function loadFlagChoices(url) { + if (flagChoicesPromise === null) { + flagChoicesPromise = fetch(url, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }) + .then((response) => (response.ok ? response.json() : { choices: [] })) + .then((data) => data.choices || []) + .catch(() => []); + } + return flagChoicesPromise; + } + + /* + * Split a flag-text string into individual flag tokens, honoring quoted + * values that may legitimately contain commas (eg. `regex:"foo,bar"`). + */ + function parseFlagInputValue(value) { + const items = []; + let current = ""; + let inQuotes = false; + let escaped = false; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (escaped) { + current += ch; + escaped = false; + continue; + } + if (ch === "\\") { + current += ch; + escaped = true; + continue; + } + if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + continue; + } + if (ch === "," && !inQuotes) { + const trimmed = current.trim(); + if (trimmed) items.push(trimmed); + current = ""; + continue; + } + current += ch; + } + const trimmed = current.trim(); + if (trimmed) items.push(trimmed); + return items; + } + + function initFlagEditor(input) { + if (input.dataset.flagEditorInitialized === "1") { + return; + } + input.dataset.flagEditorInitialized = "1"; + + const choicesUrl = input.dataset.flagChoicesUrl; + if (!choicesUrl || typeof TomSelect === "undefined") { + return; + } + + const select = document.createElement("select"); + select.multiple = true; + select.classList.add("flag-editor-select"); + for (const cls of input.classList) { + if (cls === "flag-editor") continue; + select.classList.add(cls); + } + + /* Pre-populate from current value so existing flags render immediately, + * without waiting for the catalog fetch to complete. */ + const initialFlags = parseFlagInputValue(input.value || ""); + for (const flag of initialFlags) { + const opt = document.createElement("option"); + opt.value = flag; + opt.textContent = flag; + opt.selected = true; + select.appendChild(opt); + } + + input.classList.add("d-none"); + input.setAttribute("aria-hidden", "true"); + input.tabIndex = -1; + input.parentNode.insertBefore(select, input); + + const customCategory = gettext("Custom"); + + const ts = new TomSelect(select, { + plugins: ["remove_button"], + persist: false, + create: (raw) => { + const trimmed = String(raw || "").trim(); + if (!trimmed) return false; + return { + name: trimmed, + label: trimmed, + category: customCategory, + has_value: false, + }; + }, + createOnBlur: true, + valueField: "name", + labelField: "name", + searchField: ["name", "label"], + optgroupField: "category", + optgroupLabelField: "category", + optgroupValueField: "category", + placeholder: gettext("Add a flag…"), + hidePlaceholder: false, + maxOptions: null, + render: { + option: (data, esc) => { + const sample = data.has_value + ? `${esc(data.name)}:…` + : esc(data.name); + const label = + data.label && data.label !== data.name + ? ` ${esc(data.label)}` + : ""; + return `
${sample}${label}
`; + }, + item: (data, esc) => `
${esc(data.value)}
`, + no_results: (data, esc) => + `
${esc( + interpolate( + gettext( + 'No matching flag found for "%s"; press Enter to add it as a custom flag.', + ), + [data.input], + ), + )}
`, + optgroup_header: (data, esc) => + `
${esc(data.category)}
`, + }, + }); + + ts.on("change", () => { + input.value = ts.items.join(", "); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + /* If a parametrized flag (eg. max-length) is selected without a value, + * undo the add and pre-fill the textbox with the flag name and colon so + * the user can type the value. */ + ts.on("item_add", (value) => { + const opt = ts.options[value]; + if (opt?.has_value && !String(value).includes(":")) { + ts.removeItem(value, true); + ts.setTextboxValue(`${value}:`); + ts.focus(); + } + }); + + loadFlagChoices(choicesUrl).then((choices) => { + const groups = new Set(); + for (const choice of choices) { + groups.add(choice.category); + } + for (const group of groups) { + ts.addOptionGroup(group, { category: group }); + } + for (const choice of choices) { + if (!ts.options[choice.name]) { + ts.addOption(choice); + } + } + ts.refreshOptions(false); + }); + } + + function initAll() { + document + .querySelectorAll("input.flag-editor, textarea.flag-editor") + .forEach(initFlagEditor); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initAll); + } else { + initAll(); + } + + window.initFlagEditor = initFlagEditor; +})(); diff --git a/weblate/templates/base.html b/weblate/templates/base.html index 16e96b9d6c5a..aaa5c02ac694 100644 --- a/weblate/templates/base.html +++ b/weblate/templates/base.html @@ -70,6 +70,9 @@ + diff --git a/weblate/trans/forms.py b/weblate/trans/forms.py index ade4e5223e48..92f985679eaf 100644 --- a/weblate/trans/forms.py +++ b/weblate/trans/forms.py @@ -31,7 +31,7 @@ from django.forms import model_to_dict from django.forms.utils import from_current_timezone from django.template.loader import render_to_string -from django.urls import reverse +from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.html import format_html, format_html_join from django.utils.http import urlencode @@ -225,8 +225,25 @@ def clean(self, value): raise ValidationError(gettext("Invalid checksum specified!")) from error +class FlagEditorWidget(forms.TextInput): + """Text input wired up to the tom-select-based flag editor in the UI.""" + + def __init__(self, attrs=None) -> None: + attrs = {**(attrs or {})} + existing = attrs.get("class", "").split() + if "flag-editor" not in existing: + existing.append("flag-editor") + attrs["class"] = " ".join(existing) + attrs.setdefault("autocomplete", "off") + attrs.setdefault("autocapitalize", "off") + attrs.setdefault("spellcheck", "false") + attrs.setdefault("data-flag-choices-url", reverse_lazy("js-flag-choices")) + super().__init__(attrs) + + class FlagField(forms.CharField): default_validators = [validate_check_flags] # noqa: RUF012 + widget = FlagEditorWidget class PluralTextarea(forms.Textarea): @@ -1803,6 +1820,7 @@ class Meta: field_classes = { # noqa: RUF012 "enforced_checks": SelectChecksField, "file_format_params": FormParamsField, + "check_flags": FlagField, } def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: @@ -2580,6 +2598,9 @@ class Meta: "language_aliases": forms.TextInput, "secondary_language": SortedSelect, } + field_classes = { # noqa: RUF012 + "check_flags": FlagField, + } def clean(self) -> None: data = self.cleaned_data diff --git a/weblate/trans/tests/test_js_views.py b/weblate/trans/tests/test_js_views.py index 875d28956aed..72a9075b0e64 100644 --- a/weblate/trans/tests/test_js_views.py +++ b/weblate/trans/tests/test_js_views.py @@ -18,3 +18,14 @@ def test_get_unit_translations(self) -> None: reverse("js-unit-translations", kwargs={"unit_id": unit.id}) ) self.assertContains(response, 'href="/translate/') + + def test_flag_choices(self) -> None: + response = self.client.get(reverse("js-flag-choices")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/json") + data = response.json() + self.assertIn("choices", data) + self.assertGreater(len(data["choices"]), 0) + names = {entry["name"] for entry in data["choices"]} + self.assertIn("read-only", names) + self.assertIn("max-length", names) diff --git a/weblate/trans/views/js.py b/weblate/trans/views/js.py index 41054da987d9..1af7600b9333 100644 --- a/weblate/trans/views/js.py +++ b/weblate/trans/views/js.py @@ -14,7 +14,7 @@ from django.views.decorators.cache import cache_control from django.views.decorators.http import require_POST -from weblate.checks.flags import Flags +from weblate.checks.flags import Flags, get_flag_choices from weblate.checks.models import Check from weblate.trans.models import ( Change, @@ -176,3 +176,9 @@ def matomo(request: AuthenticatedHttpRequest): return render( request, "js/matomo.js", content_type='text/javascript; charset="utf-8"' ) + + +@cache_control(max_age=3600) +def flag_choices(request: AuthenticatedHttpRequest): + """Return the catalog of known translation flags as JSON.""" + return JsonResponse({"choices": list(get_flag_choices())}) diff --git a/weblate/urls.py b/weblate/urls.py index c10d4af9517a..3c9796629ffb 100644 --- a/weblate/urls.py +++ b/weblate/urls.py @@ -769,6 +769,11 @@ name="js-catalog", ), path("js/matomo/", weblate.trans.views.js.matomo, name="js-matomo"), + path( + "js/flags/", + weblate.trans.views.js.flag_choices, + name="js-flag-choices", + ), path( "js/translate///", weblate.machinery.views.translate, From d0e37977fd1ac3e83a39774466aa2b5adba1d705 Mon Sep 17 00:00:00 2001 From: Karen Konou Date: Mon, 25 May 2026 11:02:22 +0200 Subject: [PATCH 02/14] fixes --- weblate/checks/flags.py | 101 +++++++++++++++++++------------ weblate/static/js/flag-editor.js | 32 +++++----- weblate/trans/forms.py | 10 ++- 3 files changed, 88 insertions(+), 55 deletions(-) diff --git a/weblate/checks/flags.py b/weblate/checks/flags.py index 718d66fb0fc1..b838d1a7dede 100644 --- a/weblate/checks/flags.py +++ b/weblate/checks/flags.py @@ -454,35 +454,57 @@ class FlagsValidator(Flags): FlagInput = str | etree._Element | Flags | FlagItems | None # noqa: SLF001 -# Categorization of flags for the UI flag editor. Keys are flag names that -# are not derived from a check; values are translatable category labels. +# Categories used by the UI flag editor +FLAG_CATEGORY_FORMAT: StrOrPromise = gettext_lazy("Format") +FLAG_CATEGORY_BEHAVIOR: StrOrPromise = gettext_lazy("Translation behavior") +FLAG_CATEGORY_VALIDATION: StrOrPromise = gettext_lazy("Validation") +FLAG_CATEGORY_RENDERING: StrOrPromise = gettext_lazy("Rendering") +FLAG_CATEGORY_ICU: StrOrPromise = gettext_lazy("ICU MessageFormat") +FLAG_CATEGORY_DISABLE: StrOrPromise = gettext_lazy("Disabled check") +FLAG_CATEGORY_AUTO: StrOrPromise = gettext_lazy("Automatic detection") +FLAG_CATEGORY_OTHER: StrOrPromise = gettext_lazy("Other") + +# Explicit overrides for flags whose name doesn't make the category obvious. _FLAG_CATEGORIES: dict[str, StrOrPromise] = { - "rst-text": gettext_lazy("Text format"), - "md-text": gettext_lazy("Text format"), - "xml-text": gettext_lazy("Text format"), - "url": gettext_lazy("Text format"), - "read-only": gettext_lazy("String behavior"), - "forbidden": gettext_lazy("String behavior"), - "terminology": gettext_lazy("String behavior"), - "case-insensitive": gettext_lazy("String behavior"), - "strict-same": gettext_lazy("String behavior"), - "strict-format": gettext_lazy("String behavior"), - "ignore-all-checks": gettext_lazy("String behavior"), - "priority": gettext_lazy("String behavior"), - "max-length": gettext_lazy("String behavior"), - "replacements": gettext_lazy("String behavior"), - "variant": gettext_lazy("String behavior"), - "font-family": gettext_lazy("Rendering"), - "font-size": gettext_lazy("Rendering"), - "font-weight": gettext_lazy("Rendering"), - "font-spacing": gettext_lazy("Rendering"), - "icu-flags": gettext_lazy("ICU MessageFormat"), - "icu-tag-prefix": gettext_lazy("ICU MessageFormat"), - "fluent-type": gettext_lazy("Format"), - DISCARD_FLAG: gettext_lazy("Other"), + "rst-text": FLAG_CATEGORY_FORMAT, + "md-text": FLAG_CATEGORY_FORMAT, + "xml-text": FLAG_CATEGORY_FORMAT, + "url": FLAG_CATEGORY_FORMAT, + "read-only": FLAG_CATEGORY_BEHAVIOR, + "forbidden": FLAG_CATEGORY_BEHAVIOR, + "terminology": FLAG_CATEGORY_BEHAVIOR, + "case-insensitive": FLAG_CATEGORY_BEHAVIOR, + "strict-same": FLAG_CATEGORY_BEHAVIOR, + "strict-format": FLAG_CATEGORY_BEHAVIOR, + "ignore-all-checks": FLAG_CATEGORY_BEHAVIOR, + "priority": FLAG_CATEGORY_BEHAVIOR, + "replacements": FLAG_CATEGORY_BEHAVIOR, + "variant": FLAG_CATEGORY_BEHAVIOR, + "fluent-type": FLAG_CATEGORY_BEHAVIOR, + "max-length": FLAG_CATEGORY_VALIDATION, + "max-size": FLAG_CATEGORY_VALIDATION, + "check-glossary": FLAG_CATEGORY_VALIDATION, + "font-family": FLAG_CATEGORY_RENDERING, + "font-size": FLAG_CATEGORY_RENDERING, + "font-weight": FLAG_CATEGORY_RENDERING, + "font-spacing": FLAG_CATEGORY_RENDERING, + "icu-flags": FLAG_CATEGORY_ICU, + "icu-tag-prefix": FLAG_CATEGORY_ICU, + DISCARD_FLAG: FLAG_CATEGORY_OTHER, } +def _category_for_check_flag(name: str, *, has_value: bool) -> StrOrPromise: + """Pick a sensible category for a flag derived from a check.""" + if has_value: + return FLAG_CATEGORY_VALIDATION + if name.endswith(("-format", "-text", "-interpolation", "-placeholders")): + return FLAG_CATEGORY_FORMAT + if name in {"url", "safe-html", "bbcode-text"} or name.startswith("fluent-"): + return FLAG_CATEGORY_FORMAT + return FLAG_CATEGORY_OTHER + + def _flag_choice( name: str, label: StrOrPromise, @@ -521,27 +543,26 @@ def add(name: str, label: StrOrPromise, category: StrOrPromise, has_value: bool) ) for name, label in PLAIN_FLAGS.items(): - category = _FLAG_CATEGORIES.get( - name, - gettext_lazy("Enabled check") - if name in enable_strings - else gettext_lazy("Automatic detection"), - ) + if name in _FLAG_CATEGORIES: + category: StrOrPromise = _FLAG_CATEGORIES[name] + elif name in enable_strings: + category = _category_for_check_flag(name, has_value=False) + else: + category = FLAG_CATEGORY_AUTO add(name, label, category, has_value=False) for name, label in TYPED_FLAGS.items(): - category = _FLAG_CATEGORIES.get( - name, - gettext_lazy("Parametrized check") - if name in enable_strings - else gettext_lazy("Other"), - ) + if name in _FLAG_CATEGORIES: + category = _FLAG_CATEGORIES[name] + elif name in enable_strings: + category = _category_for_check_flag(name, has_value=True) + else: + category = FLAG_CATEGORY_OTHER add(name, label, category, has_value=True) - ignore_category = gettext_lazy("Ignored check") for check in CHECKS.values(): - add(check.ignore_string, check.name, ignore_category, has_value=False) + add(check.ignore_string, check.name, FLAG_CATEGORY_DISABLE, has_value=False) for ignore_string in AUTOFIXES.get_ignore_strings(): - add(ignore_string, ignore_string, ignore_category, has_value=False) + add(ignore_string, ignore_string, FLAG_CATEGORY_DISABLE, has_value=False) return tuple(choices) diff --git a/weblate/static/js/flag-editor.js b/weblate/static/js/flag-editor.js index 63fed76891ff..92b4d6f5e59e 100644 --- a/weblate/static/js/flag-editor.js +++ b/weblate/static/js/flag-editor.js @@ -2,9 +2,7 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -// Tag-based editor for translation flag fields, powered by tom-select. -// Activates on any input/textarea carrying the "flag-editor" class and a -// "data-flag-choices-url" attribute pointing at the JSON flag catalog. +// Tag-based editor for translation flag fields (() => { let flagChoicesPromise = null; @@ -23,8 +21,7 @@ } /* - * Split a flag-text string into individual flag tokens, honoring quoted - * values that may legitimately contain commas (eg. `regex:"foo,bar"`). + * Split a flag-text string into individual flag tokens */ function parseFlagInputValue(value) { const items = []; @@ -152,17 +149,24 @@ input.dispatchEvent(new Event("change", { bubbles: true })); }); - /* If a parametrized flag (eg. max-length) is selected without a value, - * undo the add and pre-fill the textbox with the flag name and colon so - * the user can type the value. */ - ts.on("item_add", (value) => { - const opt = ts.options[value]; + /* Intercept selection of a parametrized flag without a value */ + const origAddItem = ts.addItem; + ts.addItem = function (value, silent) { + const opt = this.options[value]; if (opt?.has_value && !String(value).includes(":")) { - ts.removeItem(value, true); - ts.setTextboxValue(`${value}:`); - ts.focus(); + const typed = (this.control_input?.value || "").trim(); + const prefix = `${value}:`; + if (typed.length > prefix.length && typed.startsWith(prefix)) { + this.createItem(typed); + return; + } + this.setTextboxValue(prefix); + this.focus(); + this.refreshOptions(true); + return; } - }); + return origAddItem.call(this, value, silent); + }; loadFlagChoices(choicesUrl).then((choices) => { const groups = new Set(); diff --git a/weblate/trans/forms.py b/weblate/trans/forms.py index 0b750a6cf1f2..645c0efe86cd 100644 --- a/weblate/trans/forms.py +++ b/weblate/trans/forms.py @@ -231,7 +231,7 @@ def clean(self, value): class FlagEditorWidget(forms.TextInput): - """Text input wired up to the tom-select-based flag editor in the UI.""" + """Text input for interactive flag editor.""" def __init__(self, attrs=None) -> None: attrs = {**(attrs or {})} @@ -250,6 +250,11 @@ class FlagField(forms.CharField): default_validators = [validate_check_flags] # noqa: RUF012 widget = FlagEditorWidget + def __init__(self, *args, **kwargs) -> None: + # Force the tag-based editor widget + kwargs["widget"] = FlagEditorWidget() + super().__init__(*args, **kwargs) + class PluralTextarea(forms.Textarea): """Text-area extension which possibly handles plurals.""" @@ -1385,6 +1390,9 @@ class Meta: "labels": forms.CheckboxSelectMultiple(), "explanation": MarkdownTextarea, } + field_classes = { # noqa: RUF012 + "extra_flags": FlagField, + } doc_links: ClassVar[dict[str, tuple[str, str]]] = { "explanation": ("admin/translating", "additional-explanation"), From 2891f12774d760149d565834993ee8d146c908b9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 09:28:09 +0000 Subject: [PATCH 03/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- weblate/checks/flags.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/weblate/checks/flags.py b/weblate/checks/flags.py index b838d1a7dede..0efb9495ea7a 100644 --- a/weblate/checks/flags.py +++ b/weblate/checks/flags.py @@ -522,7 +522,8 @@ def _flag_choice( @lru_cache(maxsize=1) def get_flag_choices() -> tuple[dict[str, str | bool], ...]: - """Return catalog of all known flags for the UI flag editor. + """ + Return catalog of all known flags for the UI flag editor. Each entry contains: - ``name``: the actual flag identifier as used in the flag string @@ -534,7 +535,9 @@ def get_flag_choices() -> tuple[dict[str, str | bool], ...]: seen: set[str] = set() enable_strings = {check.enable_string for check in CHECKS.values()} - def add(name: str, label: StrOrPromise, category: StrOrPromise, has_value: bool) -> None: # noqa: FBT001 + def add( + name: str, label: StrOrPromise, category: StrOrPromise, has_value: bool + ) -> None: if name in seen: return seen.add(name) From c8ccaf94d306f829d01835addabbc6565413e263 Mon Sep 17 00:00:00 2001 From: Karen Konou Date: Mon, 25 May 2026 11:30:43 +0200 Subject: [PATCH 04/14] Changelog fix --- docs/changes.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 193f78c36334..e21ca35178a4 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -13,8 +13,7 @@ Weblate 2026.6 * Docker containers can now adjust :setting:`WEBLATE_FORMATS`. Use :envvar:`WEBLATE_ADD_FORMATS` and :envvar:`WEBLATE_REMOVE_FORMATS`. * Improved performance of the :ref:`check-inconsistent` check on large projects. -* Translation flag fields now use a tag-based editor with autocompletion - and grouped suggestions for all known flags. +* Translation flag fields now use a tag-based editor with autocompletion and grouped suggestions for all known flags. * :ref:`Contributor stats ` now de-duplicate repeated work on the same string by default, with an option to count all changes. * :doc:`/admin/code-hosting` now documents HTTPS access-token URLs and dedicated-user SSH URLs for accessing repositories. * :doc:`/admin/continuous` now explains why squash merging Weblate conflict-resolution pull requests can require a repository reset. From 11faa343a96106f6791ff3ea7e7f212929aa357b Mon Sep 17 00:00:00 2001 From: Karen Konou Date: Mon, 25 May 2026 11:56:32 +0200 Subject: [PATCH 05/14] fix tom-select render --- weblate/static/js/flag-editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/weblate/static/js/flag-editor.js b/weblate/static/js/flag-editor.js index 92b4d6f5e59e..d182cb5faff4 100644 --- a/weblate/static/js/flag-editor.js +++ b/weblate/static/js/flag-editor.js @@ -129,7 +129,7 @@ : ""; return `
${sample}${label}
`; }, - item: (data, esc) => `
${esc(data.value)}
`, + item: (data, esc) => `
${esc(data.name)}
`, no_results: (data, esc) => `
${esc( interpolate( From 9a90cbc7241822ea16aa3af2078e1425641f88ff Mon Sep 17 00:00:00 2001 From: Karen Konou Date: Tue, 26 May 2026 11:39:09 +0200 Subject: [PATCH 06/14] fix per-language flag catalog caching --- weblate/checks/flags.py | 35 ++++++++++++++++++---------- weblate/checks/tests/test_flags.py | 13 +++++++++++ weblate/trans/forms.py | 15 +++++++++--- weblate/trans/tests/test_js_views.py | 10 ++++++++ weblate/trans/views/js.py | 22 ++++++++++++++--- 5 files changed, 77 insertions(+), 18 deletions(-) diff --git a/weblate/checks/flags.py b/weblate/checks/flags.py index 0efb9495ea7a..d919f040fcc6 100644 --- a/weblate/checks/flags.py +++ b/weblate/checks/flags.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any, ClassVar from django.core.exceptions import ValidationError -from django.utils.translation import gettext, gettext_lazy +from django.utils.translation import get_language, gettext, gettext_lazy from lxml import etree from weblate.checks.models import CHECKS @@ -520,17 +520,11 @@ def _flag_choice( } -@lru_cache(maxsize=1) -def get_flag_choices() -> tuple[dict[str, str | bool], ...]: - """ - Return catalog of all known flags for the UI flag editor. - - Each entry contains: - - ``name``: the actual flag identifier as used in the flag string - - ``label``: human-readable description - - ``category``: localized category for grouping in the UI - - ``has_value``: ``True`` when the flag requires a colon-separated value - """ +@lru_cache(maxsize=16) +def _get_flag_choices_for_language( + language: str | None, # noqa: ARG001 — cache key only +) -> tuple[dict[str, str | bool], ...]: + """Build the flag catalog with labels resolved in the current language.""" choices: list[dict[str, str | bool]] = [] seen: set[str] = set() enable_strings = {check.enable_string for check in CHECKS.values()} @@ -569,3 +563,20 @@ def add( add(ignore_string, ignore_string, FLAG_CATEGORY_DISABLE, has_value=False) return tuple(choices) + + +def get_flag_choices() -> tuple[dict[str, str | bool], ...]: + """ + Return catalog of all known flags for the UI flag editor. + + The result is cached per active language so that ``gettext_lazy`` labels + and category names are resolved against the caller's locale rather than + whichever language happened to be active on the first call. + + Each entry contains: + - ``name``: the actual flag identifier as used in the flag string + - ``label``: human-readable description + - ``category``: localized category for grouping in the UI + - ``has_value``: ``True`` when the flag requires a colon-separated value + """ + return _get_flag_choices_for_language(get_language()) diff --git a/weblate/checks/tests/test_flags.py b/weblate/checks/tests/test_flags.py index 44e54e2ea529..1289ee0b043b 100644 --- a/weblate/checks/tests/test_flags.py +++ b/weblate/checks/tests/test_flags.py @@ -356,3 +356,16 @@ def test_get_flag_choices(self) -> None: self.assertFalse(read_only["has_value"]) # Names are unique (no duplicates across categories) self.assertEqual(len(names), len(choices)) + + def test_get_flag_choices_per_language(self) -> None: + from django.utils.translation import override + + with override("en"): + en_choices = get_flag_choices() + with override("cs"): + cs_choices = get_flag_choices() + with override("en"): + en_choices_again = get_flag_choices() + + self.assertIsNot(en_choices, cs_choices) + self.assertIs(en_choices, en_choices_again) diff --git a/weblate/trans/forms.py b/weblate/trans/forms.py index b64af075412b..6c65a5291f28 100644 --- a/weblate/trans/forms.py +++ b/weblate/trans/forms.py @@ -31,13 +31,13 @@ from django.forms import model_to_dict from django.forms.utils import from_current_timezone from django.template.loader import render_to_string -from django.urls import reverse, reverse_lazy +from django.urls import reverse from django.utils import timezone from django.utils.html import format_html, format_html_join from django.utils.http import urlencode from django.utils.safestring import mark_safe from django.utils.text import normalize_newlines, slugify -from django.utils.translation import gettext, gettext_lazy +from django.utils.translation import get_language, gettext, gettext_lazy from translation_finder import DiscoveryResult, discover from weblate.accounts.models import AuditLog @@ -242,9 +242,18 @@ def __init__(self, attrs=None) -> None: attrs.setdefault("autocomplete", "off") attrs.setdefault("autocapitalize", "off") attrs.setdefault("spellcheck", "false") - attrs.setdefault("data-flag-choices-url", reverse_lazy("js-flag-choices")) super().__init__(attrs) + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + # Embed active language in the URL so the browser cache key varies on it + language = get_language() or "" + url = reverse("js-flag-choices") + if language: + url = f"{url}?{urlencode({'lang': language})}" + context["widget"]["attrs"].setdefault("data-flag-choices-url", url) + return context + class FlagField(forms.CharField): default_validators = [validate_check_flags] # noqa: RUF012 diff --git a/weblate/trans/tests/test_js_views.py b/weblate/trans/tests/test_js_views.py index 72a9075b0e64..e39f41629d37 100644 --- a/weblate/trans/tests/test_js_views.py +++ b/weblate/trans/tests/test_js_views.py @@ -29,3 +29,13 @@ def test_flag_choices(self) -> None: names = {entry["name"] for entry in data["choices"]} self.assertIn("read-only", names) self.assertIn("max-length", names) + + def test_flag_choices_language_param(self) -> None: + # Unknown language is ignored + response = self.client.get( + reverse("js-flag-choices"), {"lang": "not-a-real-language"} + ) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("js-flag-choices"), {"lang": "cs"}) + self.assertEqual(response.status_code, 200) + self.assertIn("private", response.get("Cache-Control", "")) diff --git a/weblate/trans/views/js.py b/weblate/trans/views/js.py index 1af7600b9333..5db13520fcdc 100644 --- a/weblate/trans/views/js.py +++ b/weblate/trans/views/js.py @@ -5,12 +5,14 @@ from typing import TYPE_CHECKING +from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.db import transaction from django.http import HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, render from django.utils.http import urlencode +from django.utils import translation from django.views.decorators.cache import cache_control from django.views.decorators.http import require_POST @@ -178,7 +180,21 @@ def matomo(request: AuthenticatedHttpRequest): ) -@cache_control(max_age=3600) +@cache_control(max_age=3600, private=True) def flag_choices(request: AuthenticatedHttpRequest): - """Return the catalog of known translation flags as JSON.""" - return JsonResponse({"choices": list(get_flag_choices())}) + """Return the catalog of known translation flags as JSON. + + The active language is taken from the ``lang`` query parameter so the + browser cache key naturally varies per language. Without this, browser + and proxy caches keyed only on the URL would serve a stale response in + the wrong language (Weblate's UI language comes from the user profile, + not from ``Accept-Language``, so varying on that header is insufficient). + """ + requested = request.GET.get("lang") + valid_languages = {code for code, _ in settings.LANGUAGES} + if requested and requested in valid_languages: + with translation.override(requested): + choices = list(get_flag_choices()) + else: + choices = list(get_flag_choices()) + return JsonResponse({"choices": choices}) From d1a4cdb90bd742c6e3fb64a288cb947d96d48d10 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 09:40:24 +0000 Subject: [PATCH 07/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- weblate/checks/flags.py | 2 +- weblate/trans/views/js.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/weblate/checks/flags.py b/weblate/checks/flags.py index d919f040fcc6..f857964c3ce6 100644 --- a/weblate/checks/flags.py +++ b/weblate/checks/flags.py @@ -522,7 +522,7 @@ def _flag_choice( @lru_cache(maxsize=16) def _get_flag_choices_for_language( - language: str | None, # noqa: ARG001 — cache key only + language: str | None, ) -> tuple[dict[str, str | bool], ...]: """Build the flag catalog with labels resolved in the current language.""" choices: list[dict[str, str | bool]] = [] diff --git a/weblate/trans/views/js.py b/weblate/trans/views/js.py index 5db13520fcdc..9869bed1a11f 100644 --- a/weblate/trans/views/js.py +++ b/weblate/trans/views/js.py @@ -11,8 +11,8 @@ from django.db import transaction from django.http import HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, render -from django.utils.http import urlencode from django.utils import translation +from django.utils.http import urlencode from django.views.decorators.cache import cache_control from django.views.decorators.http import require_POST @@ -182,7 +182,8 @@ def matomo(request: AuthenticatedHttpRequest): @cache_control(max_age=3600, private=True) def flag_choices(request: AuthenticatedHttpRequest): - """Return the catalog of known translation flags as JSON. + """ + Return the catalog of known translation flags as JSON. The active language is taken from the ``lang`` query parameter so the browser cache key naturally varies per language. Without this, browser From d8633f299811bb673438d9698c2776affd321049 Mon Sep 17 00:00:00 2001 From: Karen Konou Date: Wed, 27 May 2026 09:45:18 +0200 Subject: [PATCH 08/14] fix lint --- weblate/checks/tests/test_flags.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/weblate/checks/tests/test_flags.py b/weblate/checks/tests/test_flags.py index 1289ee0b043b..32c377f76937 100644 --- a/weblate/checks/tests/test_flags.py +++ b/weblate/checks/tests/test_flags.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from django.test import SimpleTestCase +from django.utils.translation import override from lxml import etree from weblate.checks.flags import ( @@ -358,8 +359,6 @@ def test_get_flag_choices(self) -> None: self.assertEqual(len(names), len(choices)) def test_get_flag_choices_per_language(self) -> None: - from django.utils.translation import override - with override("en"): en_choices = get_flag_choices() with override("cs"): From 18fab889915ef65adef5640d3c610034ad9c7696 Mon Sep 17 00:00:00 2001 From: Karen Konou Date: Wed, 27 May 2026 11:06:25 +0200 Subject: [PATCH 09/14] fix test --- weblate/static/js/flag-editor.js | 4 ++++ weblate/trans/tests/test_selenium.py | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/weblate/static/js/flag-editor.js b/weblate/static/js/flag-editor.js index d182cb5faff4..6ee441ca7543 100644 --- a/weblate/static/js/flag-editor.js +++ b/weblate/static/js/flag-editor.js @@ -144,6 +144,10 @@ }, }); + if (input.id && ts.control_input) { + ts.control_input.id = `${input.id}-ts-input`; + } + ts.on("change", () => { input.value = ts.items.join(", "); input.dispatchEvent(new Event("change", { bubbles: true })); diff --git a/weblate/trans/tests/test_selenium.py b/weblate/trans/tests/test_selenium.py index 8fa561053631..7c291b40931b 100644 --- a/weblate/trans/tests/test_selenium.py +++ b/weblate/trans/tests/test_selenium.py @@ -1506,7 +1506,9 @@ def test_explanation(self) -> None: self.screenshot("source-review-edit.png") # Close modal dialog - self.driver.find_element(By.ID, "id_extra_flags").send_keys(Keys.ESCAPE) + self.driver.find_element(By.ID, "id_extra_flags-ts-input").send_keys( + Keys.ESCAPE + ) time.sleep(0.2) def test_dark_theme(self) -> None: From 6b2c684a6ed32d735f61603bb8e3a0f26bdba044 Mon Sep 17 00:00:00 2001 From: Karen Konou Date: Thu, 28 May 2026 11:46:41 +0200 Subject: [PATCH 10/14] Improve parametrized flags UX --- weblate/static/js/flag-editor.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/weblate/static/js/flag-editor.js b/weblate/static/js/flag-editor.js index 6ee441ca7543..a64205dd5b83 100644 --- a/weblate/static/js/flag-editor.js +++ b/weblate/static/js/flag-editor.js @@ -112,6 +112,13 @@ valueField: "name", labelField: "name", searchField: ["name", "label"], + /* While typing the value of a parametrized flag keep matching the + * base flag name so the known flag stays visible in the dropdown. */ + score: function (search) { + const colon = search.indexOf(":"); + const base = colon === -1 ? search : search.slice(0, colon); + return this.getScoreFunction(base); + }, optgroupField: "category", optgroupLabelField: "category", optgroupValueField: "category", From 64a90d0dd8a15c2cb26352675f14d52751943d33 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 09:47:49 +0000 Subject: [PATCH 11/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- weblate/static/js/flag-editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/weblate/static/js/flag-editor.js b/weblate/static/js/flag-editor.js index a64205dd5b83..8a0668ec501d 100644 --- a/weblate/static/js/flag-editor.js +++ b/weblate/static/js/flag-editor.js @@ -112,7 +112,7 @@ valueField: "name", labelField: "name", searchField: ["name", "label"], - /* While typing the value of a parametrized flag keep matching the + /* While typing the value of a parametrized flag keep matching the * base flag name so the known flag stays visible in the dropdown. */ score: function (search) { const colon = search.indexOf(":"); From e57b50cb51f8e98bca66cf638b133f76dd5d7f01 Mon Sep 17 00:00:00 2001 From: Karen Konou Date: Thu, 4 Jun 2026 12:10:58 +0200 Subject: [PATCH 12/14] accessibility improvements --- weblate/static/js/flag-editor.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/weblate/static/js/flag-editor.js b/weblate/static/js/flag-editor.js index 8a0668ec501d..50e0be0e9d4a 100644 --- a/weblate/static/js/flag-editor.js +++ b/weblate/static/js/flag-editor.js @@ -151,8 +151,25 @@ }, }); - if (input.id && ts.control_input) { - ts.control_input.id = `${input.id}-ts-input`; + if (ts.control_input) { + if (input.id) { + ts.control_input.id = `${input.id}-ts-input`; + // Re-point the field label at the visible TomSelect control + const selector = + typeof CSS !== "undefined" && CSS.escape + ? `label[for="${CSS.escape(input.id)}"]` + : `label[for="${input.id}"]`; + for (const label of document.querySelectorAll(selector)) { + label.htmlFor = ts.control_input.id; + } + } + // Carry over accessibility metadata + for (const attr of ["aria-label", "aria-labelledby", "aria-describedby"]) { + const value = input.getAttribute(attr); + if (value) { + ts.control_input.setAttribute(attr, value); + } + } } ts.on("change", () => { From edf371eab7717946edbb905a71824fe2a0509dd8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:12:50 +0000 Subject: [PATCH 13/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- weblate/static/js/flag-editor.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/weblate/static/js/flag-editor.js b/weblate/static/js/flag-editor.js index 50e0be0e9d4a..13f615b67c16 100644 --- a/weblate/static/js/flag-editor.js +++ b/weblate/static/js/flag-editor.js @@ -164,7 +164,11 @@ } } // Carry over accessibility metadata - for (const attr of ["aria-label", "aria-labelledby", "aria-describedby"]) { + for (const attr of [ + "aria-label", + "aria-labelledby", + "aria-describedby", + ]) { const value = input.getAttribute(attr); if (value) { ts.control_input.setAttribute(attr, value); From 35f95afce8075a8b65b6935eed4fe55f59e3a47e Mon Sep 17 00:00:00 2001 From: Karen Konou Date: Fri, 5 Jun 2026 11:00:06 +0200 Subject: [PATCH 14/14] Improve text contrast --- weblate/static/styles/main.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/weblate/static/styles/main.css b/weblate/static/styles/main.css index 44aeb8655e70..1b4d777e74b5 100644 --- a/weblate/static/styles/main.css +++ b/weblate/static/styles/main.css @@ -2855,3 +2855,7 @@ table.table-simple tbody#glossary-terms .alert-info { .ts-dropdown-content { max-height: min(60vh, 480px); } + +.ts-dropdown .create { + color: var(--bs-secondary-color); +}