Merge pull request #1379 from bhangart/feature/fr-translation-mirror

Serve the French UI from qet_fr.ts instead of the code text
This commit is contained in:
ispyisail
2026-10-08 13:40:41 +13:00
committed by GitHub
6 changed files with 6956 additions and 5146 deletions
+17
View File
@@ -363,3 +363,20 @@ and running it concurrently with the normal `lrelease` compilation step (as
would happen under `-j`/parallel builds) can corrupt a `.ts` file mid-write,
causing `lrelease` to fail with "Premature end of document". Run it on its
own, review the diff, and commit the updated `.ts` files separately.
The strings in the code are French, so `lang/qet_fr.ts` is the source
language's own file. After `update_translations`, run
```sh
python3 misc/i18n/mirror_source_language.py lang/qet_fr.ts
```
before committing: it copies every new French source string into its own
translation, so `qet_fr.qm` stays complete and French wording can later be
corrected in the `.ts` alone, without changing the key that the other
languages are attached to. Forgetting it breaks nothing (an empty entry
falls back to the code text); `--check` reports whether a run is needed.
A French correction typed into `qet_fr.ts` is lost like any other
translation when the code string is reworded; the vanished entry keeps the
old text for recovery. Never add `-removeidentical` to the `lrelease`
options: it would strip these entries again.
+6304 -5146
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="fr_FR">
<context>
<name>Alpha</name>
<message>
<location filename="../sources/alpha.cpp" line="10"/>
<source>Ouvrir le projet</source>
<translation>Ouvrir le projet</translation>
</message>
<message>
<location filename="../sources/alpha.cpp" line="11"/>
<source>Fermer le projet</source>
<translation>Fermer le projet</translation>
</message>
<message>
<location filename="../sources/alpha.cpp" line="12"/>
<source>Apply to Entire Project</source>
<translation>Appliquer au projet entier</translation>
</message>
<message>
<location filename="../sources/alpha.cpp" line="13"/>
<source>QElectroTech</source>
<translation>QElectroTech</translation>
</message>
<message>
<location filename="../sources/alpha.cpp" line="14"/>
<source>Brouillon</source>
<translation type="unfinished">Brouillon (à revoir)</translation>
</message>
<message>
<location filename="../sources/alpha.cpp" line="15"/>
<source>Copier</source>
<translation>Copier</translation>
</message>
<message numerus="yes">
<location filename="../sources/alpha.cpp" line="16"/>
<source>%n page(s)</source>
<translation>
<numerusform>%n page(s)</numerusform>
<numerusform>%n page(s)</numerusform>
</translation>
</message>
<message numerus="yes">
<location filename="../sources/alpha.cpp" line="20"/>
<source>%n élément(s)</source>
<translation>
<numerusform>%n élément</numerusform>
<numerusform>%n élément(s)</numerusform>
</translation>
</message>
<message numerus="yes">
<location filename="../sources/alpha.cpp" line="21"/>
<source>%n conducteur(s)</source>
<translation>
<numerusform>%n conducteur(s)</numerusform>
<numerusform>%n conducteur(s)</numerusform>
</translation>
</message>
<message>
<source>Ancien texte</source>
<translation type="vanished">Ancien texte</translation>
</message>
<message>
<source>Autre ancien texte</source>
<translation type="vanished"></translation>
</message>
</context>
<context>
<name>Beta</name>
<message>
<location filename="../sources/beta.cpp" line="30"/>
<source>&lt;b&gt;%1&lt;/b&gt; : %2</source>
<translation>&lt;b&gt;%1&lt;/b&gt; : %2</translation>
</message>
<message>
<location filename="../sources/beta.cpp" line="31"/>
<source>&amp;Aide</source>
<comment>menu</comment>
<translation>&amp;Aide</translation>
</message>
<message>
<location filename="../sources/beta.cpp" line="32"/>
<source>l&apos;élément &quot;x&quot;</source>
<translation>l&apos;élément &quot;x&quot;</translation>
</message>
<message>
<location filename="../sources/beta.cpp" line="33"/>
<source> Ligne 1
Ligne 2
</source>
<translation> Ligne 1
Ligne 2
</translation>
</message>
</context>
</TS>
+95
View File
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="fr_FR">
<context>
<name>Alpha</name>
<message>
<location filename="../sources/alpha.cpp" line="10"/>
<source>Ouvrir le projet</source>
<translation></translation>
</message>
<message>
<location filename="../sources/alpha.cpp" line="11"/>
<source>Fermer le projet</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../sources/alpha.cpp" line="12"/>
<source>Apply to Entire Project</source>
<translation>Appliquer au projet entier</translation>
</message>
<message>
<location filename="../sources/alpha.cpp" line="13"/>
<source>QElectroTech</source>
<translation>QElectroTech</translation>
</message>
<message>
<location filename="../sources/alpha.cpp" line="14"/>
<source>Brouillon</source>
<translation type="unfinished">Brouillon (à revoir)</translation>
</message>
<message>
<location filename="../sources/alpha.cpp" line="15"/>
<source>Copier</source>
<translation type="unfinished">Copier</translation>
</message>
<message numerus="yes">
<location filename="../sources/alpha.cpp" line="16"/>
<source>%n page(s)</source>
<translation type="unfinished">
<numerusform>%n page(s)</numerusform>
<numerusform>%n page(s)</numerusform>
</translation>
</message>
<message numerus="yes">
<location filename="../sources/alpha.cpp" line="20"/>
<source>%n élément(s)</source>
<translation>
<numerusform>%n élément</numerusform>
<numerusform></numerusform>
</translation>
</message>
<message numerus="yes">
<location filename="../sources/alpha.cpp" line="21"/>
<source>%n conducteur(s)</source>
<translation type="unfinished">
<numerusform></numerusform>
<numerusform></numerusform>
</translation>
</message>
<message>
<source>Ancien texte</source>
<translation type="vanished">Ancien texte</translation>
</message>
<message>
<source>Autre ancien texte</source>
<translation type="vanished"></translation>
</message>
</context>
<context>
<name>Beta</name>
<message>
<location filename="../sources/beta.cpp" line="30"/>
<source>&lt;b&gt;%1&lt;/b&gt; : %2</source>
<translation></translation>
</message>
<message>
<location filename="../sources/beta.cpp" line="31"/>
<source>&amp;Aide</source>
<comment>menu</comment>
<translation></translation>
</message>
<message>
<location filename="../sources/beta.cpp" line="32"/>
<source>l&apos;élément &quot;x&quot;</source>
<translation></translation>
</message>
<message>
<location filename="../sources/beta.cpp" line="33"/>
<source> Ligne 1
Ligne 2
</source>
<translation></translation>
</message>
</context>
</TS>
+233
View File
@@ -0,0 +1,233 @@
#!/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_fr.ts
python3 misc/i18n/mirror_source_language.py --check lang/qet_fr.ts
WHY THIS EXISTS
The strings in the code are French, and they are both the translation
key and the text the French UI shows. lang/qet_fr.ts was almost empty:
an empty entry falls back to the code text at run time, so French never
needed a translation. The price is that a French wording fix is a key
change, which orphans the translation of that string in every other
language file.
With every French string mirrored into qet_fr.ts, the French UI is
served from qet_fr.qm like any other language, and French wording can
be corrected in the .ts alone. The key in the code then only has to
stay stable.
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 an English source, 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
&apos; stays &apos;. 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."""
@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."""
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_fr.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, 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, 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())
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
Regression suite for mirror_source_language.py.
python3 misc/i18n/test_mirror_source_language.py
The fixtures are a hand-written pair: mirror_before.ts is what lupdate
leaves behind, mirror_after.ts is what the script must produce from it,
byte for byte. Every message class the script distinguishes appears
once in the pair, so a change in behaviour shows up as a diff in a
known place.
The last test looks at the repository's own lang/qet_fr.ts and fails
when it needs a mirror run: that is the maintenance rule from
INSTALL.md, kept honest.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import mirror_source_language as m # noqa: E402
SCRIPT = HERE / "mirror_source_language.py"
BEFORE = HERE / "fixtures" / "mirror_before.ts"
AFTER = HERE / "fixtures" / "mirror_after.ts"
REPO_FR = HERE.parent.parent / "lang" / "qet_fr.ts"
def read(path: Path) -> str:
with open(path, encoding="utf-8", newline="") as f:
return f.read()
def messages(text: str) -> dict[tuple[str, str], ET.Element]:
"""(context, source) -> <message> element."""
out = {}
for ctx in ET.fromstring(text.encode("utf-8")).iter("context"):
name = ctx.findtext("name")
for msg in ctx.iter("message"):
out[(name, msg.findtext("source"))] = msg
return out
TRANSLATION_RE = re.compile(r"<translation[^>]*>.*?</translation>", re.S)
def outside_translations(text: str) -> str:
"""The file with every <translation> element blanked out."""
return TRANSLATION_RE.sub("<translation/>", text)
class MirrorText(unittest.TestCase):
def test_nothing_to_do_is_byte_identical(self):
after = read(AFTER)
out, stats = m.mirror_text(after)
self.assertEqual(out, after)
self.assertEqual(stats.mirrored_messages, 0)
self.assertEqual(stats.mirrored_forms, 0)
self.assertEqual(stats.finished_identical, 0)
self.assertEqual(stats.unfinished_cleared, 0)
def test_before_becomes_after(self):
out, stats = m.mirror_text(read(BEFORE))
self.assertEqual(out, read(AFTER))
self.assertEqual(stats.mirrored_messages, 8)
self.assertEqual(stats.mirrored_forms, 3)
self.assertEqual(stats.finished_identical, 2)
self.assertEqual(stats.unfinished_cleared, 4)
self.assertEqual(stats.kept_translated, 1)
self.assertEqual(stats.kept_identical, 1)
self.assertEqual(stats.kept_vanished, 2)
self.assertEqual(stats.kept_unfinished_with_text, 1)
def test_nothing_outside_translation_elements_changes(self):
"""Header, locations, comments, sources and indentation are untouched."""
self.assertEqual(outside_translations(read(BEFORE)),
outside_translations(read(AFTER)))
def test_idempotent(self):
once, _ = m.mirror_text(read(BEFORE))
twice, stats = m.mirror_text(once)
self.assertEqual(once, twice)
self.assertFalse(stats.changed())
def test_untouched_classes(self):
before, after = messages(read(BEFORE)), messages(read(AFTER))
for key in [("Alpha", "Apply to Entire Project"), # English source, translated
("Alpha", "QElectroTech"), # identical
("Alpha", "Brouillon"), # unfinished with text
("Alpha", "Ancien texte"), # vanished with text
("Alpha", "Autre ancien texte")]: # vanished, empty
self.assertEqual(ET.tostring(before[key]), ET.tostring(after[key]), key)
self.assertEqual(after[("Alpha", "Brouillon")].find("translation").get("type"),
"unfinished")
def test_escaping_is_copied_verbatim(self):
"""&apos; must stay &apos;, never become &amp;apos; (a re-escape bug)."""
out, _ = m.mirror_text(read(BEFORE))
for raw in ("<translation>&lt;b&gt;%1&lt;/b&gt; : %2</translation>",
"<translation>&amp;Aide</translation>",
"<translation>l&apos;élément &quot;x&quot;</translation>"):
self.assertIn(raw, out)
for key, msg in messages(out).items():
tr = msg.find("translation")
if key[0] == "Beta":
self.assertEqual(tr.text, msg.findtext("source"), key)
def test_unfinished_identical_to_source_is_finished(self):
"""lupdate's same-text heuristic leaves these behind after a refresh;
39 of them appeared in lang/qet_fr.ts on 2026-10-07."""
out, _ = m.mirror_text(read(BEFORE))
msgs = messages(out)
for key in [("Alpha", "Copier"), ("Alpha", "%n page(s)")]:
self.assertIsNone(msgs[key].find("translation").get("type"), key)
self.assertEqual(msgs[("Alpha", "Brouillon")].find("translation").get("type"),
"unfinished", "a different unfinished text stays unfinished")
def test_multiline_and_spaces_kept(self):
out, _ = m.mirror_text(read(BEFORE))
self.assertIn("<translation> Ligne 1\nLigne 2\n</translation>", out)
def test_plural_half_empty_fills_only_the_empty_form(self):
out, _ = m.mirror_text(read(BEFORE))
forms = [f.text for f in messages(out)[("Alpha", "%n élément(s)")]
.find("translation").iter("numerusform")]
self.assertEqual(forms, ["%n élément", "%n élément(s)"])
def test_plural_both_empty_fills_both_and_clears_unfinished(self):
out, _ = m.mirror_text(read(BEFORE))
tr = messages(out)[("Alpha", "%n conducteur(s)")].find("translation")
self.assertIsNone(tr.get("type"))
self.assertEqual([f.text for f in tr.iter("numerusform")],
["%n conducteur(s)", "%n conducteur(s)"])
def test_plural_without_forms_gets_default_forms(self):
text = ('<TS><context><name>C</name><message numerus="yes">\n'
' <source>%n x</source>\n'
' <translation type="unfinished"></translation>\n'
' </message></context></TS>')
out, stats = m.mirror_text(text, forms=3)
self.assertEqual(stats.mirrored_forms, 3)
self.assertEqual(out.count("<numerusform>%n x</numerusform>"), 3)
self.assertNotIn("unfinished", out)
ET.fromstring(out)
def test_source_with_child_element_is_refused(self):
text = ('<TS><context><name>C</name><message>\n'
' <source>a<byte value="7"/>b</source>\n'
' <translation></translation>\n'
' </message></context></TS>')
with self.assertRaises(m.SourceError):
m.mirror_text(text)
class CommandLine(unittest.TestCase):
def run_script(self, *args):
return subprocess.run([sys.executable, str(SCRIPT), *args],
capture_output=True, text=True)
def copy(self, src: Path) -> Path:
d = tempfile.mkdtemp()
dst = Path(d) / src.name
dst.write_bytes(src.read_bytes())
self.addCleanup(lambda: (dst.unlink(), os.rmdir(d)))
return dst
def test_check_exit_codes(self):
self.assertEqual(self.run_script("--check", str(BEFORE)).returncode, 1)
self.assertEqual(self.run_script("--check", str(AFTER)).returncode, 0)
self.assertEqual(read(BEFORE).count("<translation></translation>"), 5,
"--check must not write")
def test_rewrite_then_check_passes(self):
tmp = self.copy(BEFORE)
r = self.run_script(str(tmp))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("mirrored 8 message(s), 3 plural form(s)", r.stdout)
self.assertEqual(read(tmp), read(AFTER))
self.assertEqual(self.run_script("--check", str(tmp)).returncode, 0)
def test_child_element_exits_2_and_leaves_file(self):
tmp = self.copy(BEFORE)
bad = read(tmp).replace("<source>Ouvrir le projet</source>",
'<source>a<byte value="7"/>b</source>')
tmp.write_text(bad, encoding="utf-8", newline="")
r = self.run_script(str(tmp))
self.assertEqual(r.returncode, 2)
self.assertIn("child element", r.stderr)
self.assertEqual(read(tmp), bad)
@unittest.skipUnless(REPO_FR.is_file(), "lang/qet_fr.ts not found next to the tool")
def test_repository_french_file_is_mirrored(self):
"""The maintenance rule: run the mirror after update_translations."""
r = self.run_script("--check", str(REPO_FR))
self.assertEqual(r.returncode, 0,
f"lang/qet_fr.ts needs a mirror run:\n{r.stdout}")
if __name__ == "__main__":
unittest.main()