diff --git a/docs/changes.rst b/docs/changes.rst index ee082ab73f85..d8f84a3ce8c0 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -63,6 +63,7 @@ Weblate 2026.6 * Docker containers can now configure :envvar:`WEBLATE_SAML_SECURITY_CONFIG` to customize SAML security settings, and adjust :setting:`WEBLATE_FORMATS` using :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. * :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, and :doc:`/admin/continuous` now explains why squash merging Weblate conflict-resolution pull requests can require a repository reset. * :ref:`alerts` now include dismissible component diagnostics for community localization. diff --git a/weblate/checks/flags.py b/weblate/checks/flags.py index 06c5d197add8..f857964c3ce6 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 @@ -452,3 +452,131 @@ class FlagsValidator(Flags): FlagInput = str | etree._Element | Flags | FlagItems | None # noqa: SLF001 + + +# 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": 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, + *, + 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=16) +def _get_flag_choices_for_language( + 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]] = [] + 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: + 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(): + 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(): + 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) + + for check in CHECKS.values(): + 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, 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 369bd1e499d7..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 ( @@ -12,6 +13,7 @@ Flags, FlagsValidator, get_auto_flag_names, + get_flag_choices, ) from weblate.formats.helpers import NamedBytesIO from weblate.formats.ttkit import PoFormat @@ -333,3 +335,36 @@ 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)) + + def test_get_flag_choices_per_language(self) -> None: + 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/static/js/flag-editor.js b/weblate/static/js/flag-editor.js new file mode 100644 index 000000000000..13f615b67c16 --- /dev/null +++ b/weblate/static/js/flag-editor.js @@ -0,0 +1,233 @@ +// Copyright © Michal Čihař +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Tag-based editor for translation flag fields + +(() => { + 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 + */ + 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"], + /* 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", + 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.name)}
`, + 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)}
`, + }, + }); + + 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", () => { + input.value = ts.items.join(", "); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + /* 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(":")) { + 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(); + 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/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); +} diff --git a/weblate/templates/base.html b/weblate/templates/base.html index c4283584e661..e8d079535e8b 100644 --- a/weblate/templates/base.html +++ b/weblate/templates/base.html @@ -72,6 +72,9 @@ + diff --git a/weblate/trans/forms.py b/weblate/trans/forms.py index 7642b7855715..2850d9a3bb6b 100644 --- a/weblate/trans/forms.py +++ b/weblate/trans/forms.py @@ -37,7 +37,7 @@ 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 @@ -250,8 +250,39 @@ def clean(self, value): raise ValidationError(gettext("Invalid checksum specified!")) from error +class FlagEditorWidget(forms.TextInput): + """Text input for interactive flag editor.""" + + 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") + 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 + 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): @@ -1421,6 +1452,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"), @@ -2128,6 +2162,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: @@ -3161,6 +3196,9 @@ class Meta: "language_code_style": SortedSelect, "license": SearchableSelect, } + field_classes = { # noqa: RUF012 + "check_flags": FlagField, + } def get_unlicensed_components(self, project_license: str) -> list[Component]: categories_by_id = { diff --git a/weblate/trans/tests/test_js_views.py b/weblate/trans/tests/test_js_views.py index 875d28956aed..e39f41629d37 100644 --- a/weblate/trans/tests/test_js_views.py +++ b/weblate/trans/tests/test_js_views.py @@ -18,3 +18,24 @@ 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) + + 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/tests/test_selenium.py b/weblate/trans/tests/test_selenium.py index 5bad1c77aede..4a4d583cfea4 100644 --- a/weblate/trans/tests/test_selenium.py +++ b/weblate/trans/tests/test_selenium.py @@ -1679,7 +1679,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: diff --git a/weblate/trans/views/js.py b/weblate/trans/views/js.py index 41054da987d9..9869bed1a11f 100644 --- a/weblate/trans/views/js.py +++ b/weblate/trans/views/js.py @@ -5,16 +5,18 @@ 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 import translation from django.utils.http import urlencode 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 +178,24 @@ def matomo(request: AuthenticatedHttpRequest): return render( request, "js/matomo.js", content_type='text/javascript; charset="utf-8"' ) + + +@cache_control(max_age=3600, private=True) +def flag_choices(request: AuthenticatedHttpRequest): + """ + 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}) diff --git a/weblate/urls.py b/weblate/urls.py index 56eaf84a7272..e9349d375f54 100644 --- a/weblate/urls.py +++ b/weblate/urls.py @@ -792,6 +792,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,