mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-09 21:54:13 +02:00
7cff54dd19
#1379 added misc/i18n/mirror_source_language.py and an INSTALL.md rule to run it on lang/qet_fr.ts after update_translations, while French was the source language. Since #1390 the code is English and qet_fr.ts is an ordinary translation, so following the rule would copy English text into empty French entries. The repository test still checked qet_fr.ts and kept passing. The rule, the script's description and the repository test now name lang/qet_en.ts. The script refuses a file whose <TS> header names a language different from its sourcelanguage (qet_fr.ts says fr_FR / en), exits 2 and leaves it untouched; files without a sourcelanguage, like the fixtures, are accepted as before. The 6 entries master added since the conversion are mirrored, so qet_en.ts is complete again. Tests: 19 in misc/i18n/test_mirror_source_language.py. The repository test fails on master (6 entries to mirror); with the check_languages() call removed, the two refusal tests fail and a run on qet_fr.ts goes through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
10 KiB
Python
Executable File
273 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright 2006-2026 The QElectroTech Team
|
|
# This file is part of QElectroTech.
|
|
#
|
|
# QElectroTech is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 2 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# QElectroTech is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
mirror_source_language.py — give every source-language string its own
|
|
translation in the source language's .ts file.
|
|
|
|
python3 misc/i18n/mirror_source_language.py lang/qet_en.ts
|
|
python3 misc/i18n/mirror_source_language.py --check lang/qet_en.ts
|
|
|
|
WHY THIS EXISTS
|
|
|
|
The strings in the code are English (French until #1390), and they are
|
|
both the translation key and the text the English UI shows. An empty
|
|
entry in lang/qet_en.ts falls back to the code text at run time, so the
|
|
source language never strictly needs a translation. The price is that an
|
|
English wording fix in the code is a key change, which orphans the
|
|
translation of that string in every other language file.
|
|
|
|
With every source string mirrored into its own .ts file, the source
|
|
language is served from its .qm like any other language, and its wording
|
|
can be corrected in the .ts alone. The key in the code then only has to
|
|
stay stable.
|
|
|
|
Only the source language's own file may be mirrored: a run on any other
|
|
file would copy English into it. The script therefore refuses a file
|
|
whose <TS> header names a language different from its sourcelanguage
|
|
(lang/qet_fr.ts says language="fr_FR" sourcelanguage="en").
|
|
|
|
WHAT IT DOES
|
|
|
|
For every message whose translation is empty, the source text is copied
|
|
into the translation and the entry is finished. Nothing else changes:
|
|
|
|
- a translation with text is never touched, whether it is a French
|
|
rendering of a source in another language, identical to the source, or marked
|
|
unfinished (a translator's work in progress), with one exception:
|
|
an unfinished translation identical to its source is finished.
|
|
That is what lupdate's same-text heuristic leaves behind when a new
|
|
string repeats an existing one, and it is what a mirror run would
|
|
have written anyway;
|
|
- vanished and obsolete entries are left to lupdate;
|
|
- in a plural message only the empty <numerusform>s are filled;
|
|
- the file stays byte-identical outside the rewritten <translation>
|
|
elements: header, locations, comments, indentation, escaping.
|
|
|
|
The source text is copied as lupdate wrote it, escaping included, so
|
|
' stays '. A <source> with a child element (lupdate's <byte/>)
|
|
is refused rather than guessed at.
|
|
|
|
Run it after `cmake --build . --target update_translations`, before
|
|
committing. Forgetting it breaks nothing: an empty entry still falls
|
|
back to the code text, and the next run fills it. `--check` exits 1
|
|
when a run would change the file, so it can gate a commit.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
import xml.etree.ElementTree as ET
|
|
|
|
MESSAGE_RE = re.compile(r"<message(?P<attrs>[^>]*)>(?P<body>.*?)</message>", re.S)
|
|
SOURCE_RE = re.compile(r"<source>(?P<text>.*?)</source>", re.S)
|
|
TRANSLATION_RE = re.compile(
|
|
r"<translation(?P<attrs>[^>]*)>(?P<body>.*?)</translation>", re.S)
|
|
FORM_RE = re.compile(r"<numerusform>(?P<text>.*?)</numerusform>", re.S)
|
|
TYPE_RE = re.compile(r'\s*type="(?P<type>[^"]*)"')
|
|
|
|
# lupdate's own layout: <translation> at 8 spaces, <numerusform> at 12.
|
|
FORM_INDENT = " " * 12
|
|
CLOSE_INDENT = " " * 8
|
|
|
|
|
|
class SourceError(ValueError):
|
|
"""A <source> that cannot be copied verbatim."""
|
|
|
|
|
|
class LanguageError(ValueError):
|
|
"""The file is a translation into another language, not a mirror."""
|
|
|
|
|
|
TS_HEADER_RE = re.compile(r"<TS\b[^>]*>")
|
|
ATTR_RE = r'\b{}="([^"]*)"'
|
|
|
|
|
|
def _base_language(code: str) -> str:
|
|
return code.replace("-", "_").split("_")[0].lower()
|
|
|
|
|
|
def check_languages(text: str) -> None:
|
|
"""Refuse a .ts whose language is not its source language.
|
|
|
|
A file without a sourcelanguage attribute is accepted: lupdate writes
|
|
one only when it knows the source language, and the fixtures and old
|
|
files have none.
|
|
"""
|
|
header = TS_HEADER_RE.search(text)
|
|
if header is None:
|
|
return
|
|
lang = re.search(ATTR_RE.format("language"), header.group(0))
|
|
source = re.search(ATTR_RE.format("sourcelanguage"), header.group(0))
|
|
if lang is None or source is None:
|
|
return
|
|
if _base_language(lang.group(1)) != _base_language(source.group(1)):
|
|
raise LanguageError(
|
|
f'the file is a translation into "{lang.group(1)}" of '
|
|
f'"{source.group(1)}" text; only the source language\'s own '
|
|
"file can be mirrored")
|
|
|
|
|
|
@dataclass
|
|
class Stats:
|
|
mirrored_messages: int = 0
|
|
mirrored_forms: int = 0
|
|
finished_identical: int = 0
|
|
unfinished_cleared: int = 0
|
|
kept_translated: int = 0
|
|
kept_identical: int = 0
|
|
kept_vanished: int = 0
|
|
kept_unfinished_with_text: int = 0
|
|
|
|
def changed(self) -> bool:
|
|
return self.mirrored_messages > 0 or self.finished_identical > 0
|
|
|
|
def summary(self) -> str:
|
|
return (
|
|
f"mirrored {self.mirrored_messages} message(s), "
|
|
f"{self.mirrored_forms} plural form(s), "
|
|
f"finished {self.finished_identical} identical unfinished, "
|
|
f"cleared {self.unfinished_cleared} unfinished flag(s); kept "
|
|
f"{self.kept_translated} translated, {self.kept_identical} identical, "
|
|
f"{self.kept_vanished} vanished, "
|
|
f"{self.kept_unfinished_with_text} unfinished with text")
|
|
|
|
|
|
def _translation_type(attrs: str) -> str:
|
|
m = TYPE_RE.search(attrs)
|
|
return m.group("type") if m else ""
|
|
|
|
|
|
def _finish(whole: str, body: str, tm: re.Match, tattrs: str,
|
|
stats: Stats) -> str:
|
|
"""Drop the unfinished flag of a translation equal to its source."""
|
|
stats.finished_identical += 1
|
|
stats.unfinished_cleared += 1
|
|
start = whole.index(body) + tm.start()
|
|
old = tm.group(0)
|
|
new = old.replace(f"<translation{tattrs}>",
|
|
f"<translation{TYPE_RE.sub('', tattrs)}>", 1)
|
|
return whole[:start] + new + whole[start + len(old):]
|
|
|
|
|
|
def _mirror_message(match: re.Match, forms: int, stats: Stats) -> str:
|
|
whole = match.group(0)
|
|
attrs, body = match.group("attrs"), match.group("body")
|
|
numerus = 'numerus="yes"' in attrs
|
|
sm = SOURCE_RE.search(body)
|
|
tm = TRANSLATION_RE.search(body)
|
|
if sm is None or tm is None:
|
|
return whole
|
|
source = sm.group("text")
|
|
if "<" in source:
|
|
raise SourceError(
|
|
"a <source> contains a child element and cannot be copied: "
|
|
+ source[:60])
|
|
tattrs, tbody = tm.group("attrs"), tm.group("body")
|
|
ttype = _translation_type(tattrs)
|
|
if ttype in ("vanished", "obsolete"):
|
|
stats.kept_vanished += 1
|
|
return whole
|
|
|
|
if numerus:
|
|
found = FORM_RE.findall(tbody)
|
|
empty = [f for f in found if f == ""]
|
|
if found and not empty:
|
|
if ttype == "unfinished" and all(f == source for f in found):
|
|
return _finish(whole, body, tm, tattrs, stats)
|
|
if ttype == "unfinished":
|
|
stats.kept_unfinished_with_text += 1
|
|
elif all(f == source for f in found):
|
|
stats.kept_identical += 1
|
|
else:
|
|
stats.kept_translated += 1
|
|
return whole
|
|
if found:
|
|
new_body = FORM_RE.sub(
|
|
lambda f: f.group(0) if f.group("text") != ""
|
|
else f"<numerusform>{source}</numerusform>", tbody)
|
|
stats.mirrored_forms += len(empty)
|
|
else:
|
|
new_body = "\n" + "".join(
|
|
f"{FORM_INDENT}<numerusform>{source}</numerusform>\n"
|
|
for _ in range(forms)) + CLOSE_INDENT
|
|
stats.mirrored_forms += forms
|
|
else:
|
|
if tbody != "":
|
|
if ttype == "unfinished" and tbody == source:
|
|
return _finish(whole, body, tm, tattrs, stats)
|
|
if ttype == "unfinished":
|
|
stats.kept_unfinished_with_text += 1
|
|
elif tbody == source:
|
|
stats.kept_identical += 1
|
|
else:
|
|
stats.kept_translated += 1
|
|
return whole
|
|
new_body = source
|
|
|
|
stats.mirrored_messages += 1
|
|
if ttype == "unfinished":
|
|
stats.unfinished_cleared += 1
|
|
kept_attrs = TYPE_RE.sub("", tattrs)
|
|
new_translation = f"<translation{kept_attrs}>{new_body}</translation>"
|
|
new_message_body = body[:tm.start()] + new_translation + body[tm.end():]
|
|
return f"<message{attrs}>{new_message_body}</message>"
|
|
|
|
|
|
def mirror_text(text: str, forms: int = 2) -> tuple[str, Stats]:
|
|
"""Return the mirrored .ts text and what was done to it."""
|
|
check_languages(text)
|
|
stats = Stats()
|
|
new = MESSAGE_RE.sub(lambda m: _mirror_message(m, forms, stats), text)
|
|
return new, stats
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Copy each empty translation's source text into the "
|
|
"translation of a source-language .ts file.")
|
|
parser.add_argument("ts", type=Path, help="the .ts file, e.g. lang/qet_en.ts")
|
|
parser.add_argument("--check", action="store_true",
|
|
help="change nothing; exit 1 if a run would change the file")
|
|
parser.add_argument("--forms", type=int, default=2,
|
|
help="plural forms to write when a plural message has "
|
|
"none (default 2, as in English and French)")
|
|
args = parser.parse_args(argv)
|
|
|
|
with open(args.ts, encoding="utf-8", newline="") as f:
|
|
text = f.read()
|
|
try:
|
|
new, stats = mirror_text(text, args.forms)
|
|
ET.fromstring(new.encode("utf-8"))
|
|
except (SourceError, LanguageError, ET.ParseError) as e:
|
|
print(f"{args.ts}: {e}", file=sys.stderr)
|
|
return 2
|
|
print(f"{args.ts}: {stats.summary()}")
|
|
if args.check:
|
|
return 1 if stats.changed() else 0
|
|
if new != text:
|
|
with open(args.ts, "w", encoding="utf-8", newline="") as f:
|
|
f.write(new)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|