diff --git a/misc/i18n/fixtures/mirror_after.ts b/misc/i18n/fixtures/mirror_after.ts
index c83caeb28..0309fd987 100644
--- a/misc/i18n/fixtures/mirror_after.ts
+++ b/misc/i18n/fixtures/mirror_after.ts
@@ -28,6 +28,19 @@
Brouillon
Brouillon (à revoir)
+
+
+ Copier
+ Copier
+
+
+
+ %n page(s)
+
+ %n page(s)
+ %n page(s)
+
+
%n élément(s)
diff --git a/misc/i18n/fixtures/mirror_before.ts b/misc/i18n/fixtures/mirror_before.ts
index 48e1aaf4d..613011833 100644
--- a/misc/i18n/fixtures/mirror_before.ts
+++ b/misc/i18n/fixtures/mirror_before.ts
@@ -28,6 +28,19 @@
Brouillon
Brouillon (à revoir)
+
+
+ Copier
+ Copier
+
+
+
+ %n page(s)
+
+ %n page(s)
+ %n page(s)
+
+
%n élément(s)
diff --git a/misc/i18n/mirror_source_language.py b/misc/i18n/mirror_source_language.py
index 2f7c7e469..dab5a9b1a 100755
--- a/misc/i18n/mirror_source_language.py
+++ b/misc/i18n/mirror_source_language.py
@@ -42,7 +42,11 @@ 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);
+ 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 s are filled;
- the file stays byte-identical outside the rewritten
@@ -87,6 +91,7 @@ class SourceError(ValueError):
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
@@ -94,12 +99,13 @@ class Stats:
kept_unfinished_with_text: int = 0
def changed(self) -> bool:
- return self.mirrored_messages > 0
+ 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, "
@@ -111,6 +117,18 @@ def _translation_type(attrs: str) -> str:
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"",
+ f"", 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")
@@ -134,6 +152,8 @@ def _mirror_message(match: re.Match, forms: int, stats: Stats) -> str:
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):
@@ -153,6 +173,8 @@ def _mirror_message(match: re.Match, forms: int, stats: Stats) -> str:
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:
diff --git a/misc/i18n/test_mirror_source_language.py b/misc/i18n/test_mirror_source_language.py
index 92aa1ffa2..7b462d4e3 100755
--- a/misc/i18n/test_mirror_source_language.py
+++ b/misc/i18n/test_mirror_source_language.py
@@ -66,6 +66,7 @@ class MirrorText(unittest.TestCase):
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):
@@ -73,7 +74,8 @@ class MirrorText(unittest.TestCase):
self.assertEqual(out, read(AFTER))
self.assertEqual(stats.mirrored_messages, 8)
self.assertEqual(stats.mirrored_forms, 3)
- self.assertEqual(stats.unfinished_cleared, 2)
+ 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)
@@ -113,6 +115,16 @@ class MirrorText(unittest.TestCase):
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(" Ligne 1\nLigne 2\n", out)