Skip to content

Commit f38d03c

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 Be closer to qtlinguist semantics
1 parent 5465396 commit f38d03c

File tree

2 files changed

+241
-0
lines changed

2 files changed

+241
-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: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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+
# Regex helpers
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+
class Severity(IntEnum):
60+
WARNING = 1
61+
SEVERE = 2
62+
63+
# Data structures
64+
@dataclass(frozen=True)
65+
class MessageContext:
66+
ts_file: Path
67+
line: int
68+
lang: str
69+
source: str
70+
translation: str
71+
tr_type: str
72+
excerpt: str
73+
74+
@dataclass(frozen=True)
75+
class WarningItem:
76+
ts_file: Path
77+
line: int
78+
message: str
79+
severity: Severity
80+
81+
# Helpers
82+
def approximate_message_lines(text: str):
83+
"""Yield approximate line numbers for <message> elements."""
84+
lines = text.splitlines()
85+
cursor = 0
86+
for _ in range(text.count("<message")):
87+
for i in range(cursor, len(lines)):
88+
if "<message" in lines[i]:
89+
cursor = i + 1
90+
yield i + 1
91+
break
92+
else:
93+
yield 0
94+
95+
# Checks
96+
def check_language_header(ts_file: Path, root):
97+
file_lang = ts_file.stem.replace("translation_", "")
98+
header_lang = root.attrib.get("language", "")
99+
if header_lang != file_lang:
100+
return [WarningItem(ts_file, 0,
101+
f"Language header mismatch '{header_lang}' != '{file_lang}'",
102+
Severity.WARNING)]
103+
return []
104+
105+
def check_empty_translation(ctx: MessageContext):
106+
if not ctx.translation and ctx.tr_type != "unfinished":
107+
return [WarningItem(ctx.ts_file, ctx.line,
108+
f"{ctx.lang}: empty translation for '{ctx.excerpt}...'",
109+
Severity.SEVERE)]
110+
return []
111+
112+
def check_placeholders(ctx: MessageContext):
113+
if ctx.tr_type != "unfinished" and set(PLACEHOLDER_RE.findall(ctx.source)) != set(PLACEHOLDER_RE.findall(ctx.translation)):
114+
return [WarningItem(ctx.ts_file, ctx.line,
115+
f"{ctx.lang}: placeholder mismatch for '{ctx.excerpt}...'\n"
116+
f"Source: {ctx.source}\nTranslation: {ctx.translation}",
117+
Severity.WARNING)]
118+
return []
119+
120+
def check_html(ctx: MessageContext):
121+
if HTML_TAG_RE.search(ctx.source) and not HTML_TAG_RE.search(ctx.translation) and ctx.tr_type != "unfinished":
122+
return [WarningItem(ctx.ts_file, ctx.line,
123+
f"{ctx.lang}: HTML missing for '{ctx.excerpt}...'\n"
124+
f"Source: {ctx.source}\nTranslation: {ctx.translation}",
125+
Severity.WARNING)]
126+
return []
127+
128+
def check_whitespace(ctx: MessageContext):
129+
if ctx.source != ctx.source.strip() or ctx.translation != ctx.translation.strip():
130+
return [WarningItem(ctx.ts_file, ctx.line,
131+
f"{ctx.lang}: leading/trailing whitespace difference for '{ctx.excerpt}...'",
132+
Severity.WARNING)]
133+
return []
134+
135+
def check_newline_consistency(ctx: MessageContext):
136+
if ctx.source.endswith("\n") != ctx.translation.endswith("\n"):
137+
return [WarningItem(ctx.ts_file, ctx.line,
138+
f"{ctx.lang}: newline mismatch for '{ctx.excerpt}...'",
139+
Severity.WARNING)]
140+
return []
141+
142+
# Detect warnings
143+
def detect_warnings(ts_file: Path):
144+
try:
145+
text = ts_file.read_text(encoding="utf-8")
146+
root = ET.fromstring(text)
147+
except (OSError, ET.ParseError) as exc:
148+
return [WarningItem(ts_file, 0,
149+
f"Error reading or parsing XML: {exc}",
150+
Severity.SEVERE)]
151+
152+
warnings = []
153+
warnings.extend(check_language_header(ts_file, root))
154+
155+
file_lang = ts_file.stem.replace("translation_", "")
156+
message_lines = approximate_message_lines(text)
157+
158+
for context in root.findall("context"):
159+
for message, line in zip(context.findall("message"), message_lines):
160+
source = (message.findtext("source") or "").strip()
161+
tr_elem = message.find("translation")
162+
translation = ""
163+
tr_type = ""
164+
if tr_elem is not None:
165+
translation = (tr_elem.text or "").strip()
166+
tr_type = tr_elem.attrib.get("type", "")
167+
excerpt = source[:30].replace("\n", " ")
168+
169+
ctx = MessageContext(ts_file, line, file_lang, source, translation, tr_type, excerpt)
170+
171+
# All checks
172+
warnings.extend(check_empty_translation(ctx))
173+
warnings.extend(check_placeholders(ctx))
174+
warnings.extend(check_html(ctx))
175+
warnings.extend(check_whitespace(ctx))
176+
warnings.extend(check_newline_consistency(ctx))
177+
178+
return warnings
179+
180+
# CLI
181+
def main():
182+
parser = argparse.ArgumentParser(description="Qt TS translation checker with extended rules")
183+
parser.add_argument("--ts-dir", type=Path, default=Path("src/translation"),
184+
help="Directory containing translation_*.ts files")
185+
parser.add_argument("--strict", action="store_true",
186+
help="Exit non-zero if any warning is found")
187+
args = parser.parse_args()
188+
189+
if not args.ts_dir.exists():
190+
print(f"Directory not found: {args.ts_dir}", file=sys.stderr)
191+
return 2
192+
ts_files = sorted(args.ts_dir.glob("translation_*.ts"))
193+
if not ts_files:
194+
print(f"No TS files found in {args.ts_dir}", file=sys.stderr)
195+
return 2
196+
197+
all_warnings = []
198+
for ts_file in ts_files:
199+
all_warnings.extend(detect_warnings(ts_file))
200+
201+
grouped = defaultdict(list)
202+
for w in all_warnings:
203+
grouped[(w.ts_file, w.line)].append(w)
204+
205+
# Detailed output
206+
for (file, line), messages in sorted(grouped.items()):
207+
for w in messages:
208+
color = RED if w.severity == Severity.SEVERE else YELLOW
209+
print(f"{BOLD}{file}{RESET} {CYAN}line {line}{RESET}: {color}{w.message}{RESET}")
210+
211+
# Test summary
212+
failures_by_language = defaultdict(lambda: {"severe":0, "warning":0})
213+
all_languages = set()
214+
215+
for w in all_warnings:
216+
lang = w.ts_file.stem.replace("translation_", "")
217+
all_languages.add(lang)
218+
if w.severity == Severity.SEVERE:
219+
failures_by_language[lang]["severe"] += 1
220+
else:
221+
failures_by_language[lang]["warning"] += 1
222+
223+
print("\n== Test Summary ==")
224+
for lang in sorted(all_languages):
225+
counts = failures_by_language[lang]
226+
print(f"{BOLD}[{lang}]{RESET} Severe: {counts['severe']}, Warnings: {counts['warning']}")
227+
228+
total_severe = sum(f["severe"] for f in failures_by_language.values())
229+
total_warning = sum(f["warning"] for f in failures_by_language.values())
230+
print(f"\nTotal Severe: {total_severe}, Total Warnings: {total_warning}")
231+
232+
if total_severe > 0 or (args.strict and total_warning > 0):
233+
return 1
234+
235+
return 0
236+
237+
if __name__ == "__main__":
238+
sys.exit(main())

0 commit comments

Comments
 (0)