diff --git a/docs/developer_docs/contributing/howtos.md b/docs/developer_docs/contributing/howtos.md index 405a6311c35..438f25690f6 100644 --- a/docs/developer_docs/contributing/howtos.md +++ b/docs/developer_docs/contributing/howtos.md @@ -342,6 +342,12 @@ uses Claude AI to generate draft translations for any missing entries. All AI-generated strings are marked `#, fuzzy` and tagged with an attribution comment so that human reviewers know they need to be checked before merging. +The script never touches entries that must stay literal — msgids in its curated +`DO_NOT_TRANSLATE` set (icon names, enum values, SQL keywords, API field names, +example placeholders) and any entry carrying an explicit do-not-translate +translator comment (e.g. the ru catalog's `# Не переводить`). Those are left +untranslated so they fall back to the source token. + #### Prerequisites ```bash diff --git a/scripts/translations/backfill_po.py b/scripts/translations/backfill_po.py index 25eed9ed444..b5cbffd0c4e 100644 --- a/scripts/translations/backfill_po.py +++ b/scripts/translations/backfill_po.py @@ -152,6 +152,60 @@ def _is_missing(entry: polib.POEntry) -> bool: return not entry.msgstr +# msgids that must never be machine-translated: literal tokens compared against +# source (SQL keywords, confirmation words), enum values (d3 interpolation +# modes), icon names (e.g. "bolt" -> the ⚡ Explore control icon), API field +# names, code constants, and example placeholders. Seeded from the +# "# Не переводить" (do-not-translate) markers a human translator curated in the +# ru catalog; these strings are language-independent, so the set applies to +# every catalog. Translating them can break icon lookups, enum matching, or API +# contracts, or is simply meaningless (proper nouns, example values). +DO_NOT_TRANSLATE: frozenset[str] = frozenset( + { + "10000", + "DELETE", + "ECharts", + "EMAIL_REPORTS_CTA", + "GROUP BY", + "NOT GROUPED BY", + "OVERWRITE", + "TEMPORAL_RANGE", + "WFS", + "WMS", + "XYZ", + "bolt", + "crontab", + "error_message", + "pivoted_xlsx", + "schema1,schema2", + "sql", + "step-after", + "step-before", + "superset.example.com", + "your-project-1234-a1", + } +) + +# Translator comment (in any catalog) explicitly marking an entry off-limits, +# e.g. the ru catalog's "# Не переводить". Honored so a human translator's +# deliberate decision to leave a string untranslated is never overridden. +_DO_NOT_TRANSLATE_COMMENT = re.compile( + r"не\s+переводить|do[\s-]?not[\s-]?translate|don'?t\s+translate", + re.IGNORECASE, +) + + +def _is_do_not_translate(entry: polib.POEntry) -> bool: + """Return True if an entry must be left for a human (never machine-filled). + + Either its msgid is in the curated DO_NOT_TRANSLATE set, or a translator + comment explicitly marks it do-not-translate. + """ + if entry.msgid in DO_NOT_TRANSLATE: + return True + return bool(entry.tcomment and _DO_NOT_TRANSLATE_COMMENT.search(entry.tcomment)) + + def _context_langs( item: dict[str, Any], index: dict[str, Any], target_lang: str ) -> list[str]: @@ -647,6 +701,15 @@ def backfill( missing: list[polib.POEntry] = [e for e in cat if e.msgid and _is_missing(e)] print(f"Found {len(missing)} untranslated entries for '{lang}'.", file=sys.stderr) + skipped_dnt = [e for e in missing if _is_do_not_translate(e)] + if skipped_dnt: + missing = [e for e in missing if not _is_do_not_translate(e)] + print( + f"Skipping {len(skipped_dnt)} do-not-translate entries (literal " + f"tokens / translator-marked); they are left untranslated.", + file=sys.stderr, + ) + if min_context > 0: before = len(missing) missing = [ diff --git a/tests/unit_tests/scripts/translations/backfill_po_test.py b/tests/unit_tests/scripts/translations/backfill_po_test.py index 6734362b0ae..f9b81e9f3cf 100644 --- a/tests/unit_tests/scripts/translations/backfill_po_test.py +++ b/tests/unit_tests/scripts/translations/backfill_po_test.py @@ -436,3 +436,29 @@ def test_resilient_translate_propagates_runtime_error( monkeypatch.setattr(backfill_po, "translate_batch", _boom) with pytest.raises(RuntimeError): backfill_po._resilient_translate("m", "fr", [_qitem("Alpha")], {}) + + +# --- _is_do_not_translate: never machine-fill literal tokens ------------------- + + +def test_is_do_not_translate_curated_msgid() -> None: + """A msgid in the curated DO_NOT_TRANSLATE set is protected (icon names, + enum values, SQL keywords, API field names, placeholders).""" + for msgid in ("bolt", "error_message", "step-after", "GROUP BY"): + assert backfill_po._is_do_not_translate(polib.POEntry(msgid=msgid, msgstr="")) + + +def test_is_do_not_translate_honors_translator_comment() -> None: + """An explicit do-not-translate translator comment is honored, in any + language (e.g. the ru catalog's Cyrillic marker) and phrasing.""" + for comment in ("Не переводить", "do not translate", "DO-NOT-TRANSLATE"): + entry = polib.POEntry(msgid="Some label", msgstr="") + entry.tcomment = comment + assert backfill_po._is_do_not_translate(entry) + + +def test_is_do_not_translate_allows_normal_entry() -> None: + """An ordinary translatable string is not flagged.""" + entry = polib.POEntry(msgid="Save dashboard", msgstr="") + entry.tcomment = "Machine-translated via backfill_po.py (claude-x) [no refs]" + assert not backfill_po._is_do_not_translate(entry)