Skip to content

Commit 8a23c2c

Browse files
committed
Add annotation tests for translations
Make script executable Add PR commenting logic Add pygithub dependency Fix import Remove GitHub requirements Refactor test suite Fix some errors Add styling Add severity
1 parent 5465396 commit 8a23c2c

File tree

2 files changed

+238
-0
lines changed

2 files changed

+238
-0
lines changed

.github/workflows/translation-check.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ on:
1010
push:
1111
paths:
1212
- 'src/translation/wininstaller/**'
13+
- 'src/translation/*.ts'
1314
- 'tools/check-wininstaller-translations.sh'
1415
- '.github/workflows/translation-check.yml'
1516

@@ -24,5 +25,7 @@ jobs:
2425
uses: actions/checkout@v6
2526
- name: "Check Windows installer translations"
2627
run: ./tools/check-wininstaller-translations.sh
28+
- name: "Check application translations"
29+
run: pip install PyGithub && ./tools/check-translations.py
2730
#- name: "Check for duplicate hotkeys (will not fail)"
2831
# run: sudo apt install libxml-simple-perl && cd src/translation/ && perl ./tools/checkkeys.pl

tools/check-translations.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
#!/usr/bin/env python3
2+
#
3+
##############################################################################
4+
# Copyright (c) 2026
5+
#
6+
# Author(s):
7+
# ChatGPT
8+
# ann0see
9+
# The Jamulus Development Team
10+
#
11+
##############################################################################
12+
#
13+
# This program is free software; you can redistribute it and/or modify it under
14+
# the terms of the GNU General Public License as published by the Free Software
15+
# Foundation; either version 2 of the License, or (at your option) any later
16+
# version.
17+
#
18+
# This program is distributed in the hope that it will be useful, but WITHOUT
19+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
20+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
21+
# details.
22+
#
23+
# You should have received a copy of the GNU General Public License along with
24+
# this program; if not, write to the Free Software Foundation, Inc.,
25+
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
26+
#
27+
##############################################################################
28+
29+
"""
30+
Qt TS translation checker.
31+
32+
This tool validates Qt `.ts` translation files according to Qt Linguist
33+
semantics.
34+
Warnings are reported with best-effort line numbers. In strict mode, the
35+
presence of any warning results in a non-zero exit code to allow CI failure.
36+
"""
37+
38+
import argparse
39+
import re
40+
import sys
41+
import xml.etree.ElementTree as ET
42+
from collections import defaultdict
43+
from dataclasses import dataclass
44+
from enum import IntEnum
45+
from pathlib import Path
46+
47+
# Qt-style placeholders
48+
PLACEHOLDER_RE = re.compile(r"%\d+")
49+
HTML_TAG_RE = re.compile(r"<[^>]+>")
50+
51+
# ANSI escape codes
52+
BOLD = "\033[1m"
53+
CYAN = "\033[36m"
54+
YELLOW = "\033[33m"
55+
RED = "\033[31m"
56+
RESET = "\033[0m"
57+
58+
# ---------- Severity Enum ----------
59+
60+
class Severity(IntEnum):
61+
WARNING = 1
62+
SEVERE = 2
63+
64+
# ---------- helpers ----------
65+
66+
def approximate_message_lines(text: str):
67+
"""Yield approximate line numbers for <message> elements."""
68+
lines = text.splitlines()
69+
cursor = 0
70+
for _ in range(text.count("<message")):
71+
for i in range(cursor, len(lines)):
72+
if "<message" in lines[i]:
73+
cursor = i + 1
74+
yield i + 1
75+
break
76+
else:
77+
yield 0
78+
79+
# ---------- data structures ----------
80+
81+
@dataclass(frozen=True)
82+
class MessageContext:
83+
ts_file: Path
84+
line: int
85+
lang: str
86+
source: str
87+
translation: str
88+
tr_type: str
89+
excerpt: str
90+
91+
@dataclass(frozen=True)
92+
class WarningItem:
93+
ts_file: Path
94+
line: int
95+
message: str
96+
severity: Severity
97+
98+
# ---------- checks ----------
99+
100+
def check_language_header(ts_file: Path, root):
101+
"""Header mismatch is a warning (not severe)."""
102+
file_lang = ts_file.stem.replace("translation_", "")
103+
header_lang = root.attrib.get("language", "")
104+
if header_lang != file_lang:
105+
return [WarningItem(ts_file, 0,
106+
f"Language header mismatch '{header_lang}' != '{file_lang}'",
107+
Severity.WARNING)]
108+
return []
109+
110+
def check_empty_translation(ctx: MessageContext):
111+
"""Empty translation is SEVERE."""
112+
if not ctx.translation and ctx.tr_type != "unfinished":
113+
return [WarningItem(ctx.ts_file, ctx.line,
114+
f"{ctx.lang}: empty translation for '{ctx.excerpt}...'",
115+
Severity.SEVERE)]
116+
return []
117+
118+
def check_placeholders(ctx: MessageContext):
119+
"""Placeholder mismatch is a WARNING."""
120+
if set(PLACEHOLDER_RE.findall(ctx.source)) != set(PLACEHOLDER_RE.findall(ctx.translation)):
121+
return [WarningItem(ctx.ts_file, ctx.line,
122+
f"{ctx.lang}: placeholder mismatch for '{ctx.excerpt}...'\n"
123+
f"Source: {ctx.source}\nTranslation: {ctx.translation}",
124+
Severity.WARNING)]
125+
return []
126+
127+
def check_html(ctx: MessageContext):
128+
"""HTML missing in translation is a WARNING."""
129+
if HTML_TAG_RE.search(ctx.source) and not HTML_TAG_RE.search(ctx.translation) and ctx.tr_type != "unfinished":
130+
return [WarningItem(ctx.ts_file, ctx.line,
131+
f"{ctx.lang}: HTML missing for '{ctx.excerpt}...'\n"
132+
f"Source: {ctx.source}\nTranslation: {ctx.translation}",
133+
Severity.WARNING)]
134+
return []
135+
136+
# ---------- test runner ----------
137+
138+
def detect_warnings(ts_file: Path):
139+
"""Run all checks and return list of WarningItem."""
140+
try:
141+
text = ts_file.read_text(encoding="utf-8")
142+
root = ET.fromstring(text)
143+
except (OSError, ET.ParseError) as exc:
144+
return [WarningItem(ts_file, 0,
145+
f"Error reading or parsing XML: {exc}",
146+
Severity.SEVERE)]
147+
148+
warnings = []
149+
warnings.extend(check_language_header(ts_file, root))
150+
151+
file_lang = ts_file.stem.replace("translation_", "")
152+
message_lines = approximate_message_lines(text)
153+
154+
for context in root.findall("context"):
155+
for message, line in zip(context.findall("message"), message_lines):
156+
source = (message.findtext("source") or "").strip()
157+
tr_elem = message.find("translation")
158+
translation = ""
159+
tr_type = ""
160+
if tr_elem is not None:
161+
translation = (tr_elem.text or "").strip()
162+
tr_type = tr_elem.attrib.get("type", "")
163+
excerpt = source[:30].replace("\n", " ")
164+
165+
ctx = MessageContext(ts_file, line, file_lang, source, translation, tr_type, excerpt)
166+
warnings.extend(check_empty_translation(ctx))
167+
warnings.extend(check_placeholders(ctx))
168+
warnings.extend(check_html(ctx))
169+
return warnings
170+
171+
# ---------- CLI harness ----------
172+
173+
def main():
174+
parser = argparse.ArgumentParser(description="Qt TS translation checker with severity")
175+
parser.add_argument("--ts-dir", type=Path, default=Path("src/translation"),
176+
help="Directory containing translation_*.ts files")
177+
parser.add_argument("--strict", action="store_true",
178+
help="Exit non-zero if any warning is found")
179+
args = parser.parse_args()
180+
181+
if not args.ts_dir.exists():
182+
print(f"Directory not found: {args.ts_dir}", file=sys.stderr)
183+
return 2
184+
ts_files = sorted(args.ts_dir.glob("translation_*.ts"))
185+
if not ts_files:
186+
print(f"No TS files found in {args.ts_dir}", file=sys.stderr)
187+
return 2
188+
189+
all_warnings = []
190+
for ts_file in ts_files:
191+
all_warnings.extend(detect_warnings(ts_file))
192+
193+
# Group warnings for output
194+
grouped = defaultdict(list)
195+
for w in all_warnings:
196+
grouped[(w.ts_file, w.line)].append(w)
197+
198+
# --- detailed output ---
199+
for (file, line), messages in sorted(grouped.items()):
200+
for w in messages:
201+
color = RED if w.severity == Severity.SEVERE else YELLOW
202+
print(f"{BOLD}{file}{RESET} {CYAN}line {line}{RESET}: {color}{w.message}{RESET}")
203+
204+
# --- test suite style summary ---
205+
failures_by_language = defaultdict(lambda: {"severe":0, "warning":0})
206+
all_languages = set()
207+
has_severe = False
208+
209+
for w in all_warnings:
210+
lang = w.ts_file.stem.replace("translation_", "")
211+
all_languages.add(lang)
212+
if w.severity == Severity.SEVERE:
213+
failures_by_language[lang]["severe"] += 1
214+
has_severe = True
215+
else:
216+
failures_by_language[lang]["warning"] += 1
217+
218+
print("\n== Test Summary ==")
219+
for lang in sorted(all_languages):
220+
counts = failures_by_language[lang]
221+
print(f"{BOLD}[{lang}]{RESET} Severe: {counts['severe']}, Warnings: {counts['warning']}")
222+
223+
total_severe = sum(f["severe"] for f in failures_by_language.values())
224+
total_warning = sum(f["warning"] for f in failures_by_language.values())
225+
print(f"\nTotal Severe: {total_severe}, Total Warnings: {total_warning}")
226+
227+
# Exit on severe errors or strict mode
228+
if has_severe or (args.strict and total_warning > 0):
229+
return 1
230+
231+
return 0
232+
233+
if __name__ == "__main__":
234+
sys.exit(main())
235+

0 commit comments

Comments
 (0)