Skip to content

Commit cc40a8a

Browse files
JaminShantiann0seeRadjammin@gmail.com
authored
Translation linter work - Rework of #3597 (#3683)
* 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 * Adding 3 fixes * Adding whitespace comparison * Reduced output to bring attention to findings * update to trigger actions * update to trigger actions * changes requested in PR * python coding style checker changes * python coding style checker changes * changes requested in PR * Requested changes for PR 3683 * pylint error resolved * pylint error resolved * pylint error resolved * Updated license header * updated per request --------- Co-authored-by: ann0see <20726856+ann0see@users.noreply.github.com> Co-authored-by: Radjammin@gmail.com <my@eail.com>
1 parent bf63b42 commit cc40a8a

2 files changed

Lines changed: 285 additions & 3 deletions

File tree

.github/workflows/translation-check.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ on:
55
pull_request:
66
paths:
77
- "src/translation/wininstaller/**"
8+
- "src/translation/*.ts"
89
- "tools/check-wininstaller-translations.sh"
10+
- "tools/check-translations.py"
911
- ".github/workflows/translation-check.yml"
1012
push:
1113
paths:
12-
- "src/translation/wininstaller/**"
13-
- "tools/check-wininstaller-translations.sh"
14-
- ".github/workflows/translation-check.yml"
14+
- 'src/translation/wininstaller/**'
15+
- 'src/translation/*.ts'
16+
- 'tools/check-wininstaller-translations.sh'
17+
- 'tools/check-translations.py'
18+
- '.github/workflows/translation-check.yml'
1519

1620
jobs:
1721
translation-check:
@@ -26,3 +30,5 @@ jobs:
2630
run: ./tools/check-wininstaller-translations.sh
2731
- name: "Check for duplicate hotkeys (will not fail)"
2832
run: perl ./tools/checkkeys.pl
33+
- name: "Check application translations"
34+
run: ./tools/check-translations.py --ts-dir src/translation

tools/check-translations.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
#!/usr/bin/env python3
2+
#
3+
##############################################################################
4+
# Copyright (c) 2026
5+
#
6+
# Author(s):
7+
# JaminShanti
8+
# ann0see
9+
# The Jamulus Development Team
10+
#
11+
# Code generated with assistance from:
12+
# ChatGPT
13+
# Gemini
14+
#
15+
##############################################################################
16+
#
17+
# This program is free software: you can redistribute it and/or modify it under
18+
# the terms of the GNU Affero General Public License as published by the Free
19+
# Software Foundation, either version 3 of the License, or (at your option)
20+
# any later version.
21+
#
22+
# This program is distributed in the hope that it will be useful, but WITHOUT
23+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
24+
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
25+
# details.
26+
#
27+
# You should have received a copy of the GNU Affero General Public License
28+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
29+
#
30+
##############################################################################
31+
32+
"""
33+
Qt TS translation checker.
34+
35+
This tool validates Qt `.ts` translation files according to Qt Linguist
36+
semantics.
37+
"""
38+
39+
import argparse
40+
import re
41+
import sys
42+
import xml.etree.ElementTree as ET
43+
import xml.sax
44+
from collections import defaultdict, Counter
45+
from dataclasses import dataclass
46+
from enum import IntEnum
47+
from pathlib import Path
48+
49+
# Regex helpers
50+
PLACEHOLDER_RE = re.compile(r"%\d+")
51+
HTML_TAG_RE = re.compile(r"<[^>]+>")
52+
53+
54+
class Colors:
55+
"""ANSI escape codes for terminal output."""
56+
BOLD = "\033[1m"
57+
CYAN = "\033[36m"
58+
YELLOW = "\033[33m"
59+
RED = "\033[31m"
60+
RESET = "\033[0m"
61+
62+
63+
def configure_colors(color_arg: str):
64+
"""Disable color constants if outputting to a non-TTY without 'always' override."""
65+
if color_arg == 'never' or (color_arg == 'auto' and not sys.stdout.isatty()):
66+
Colors.BOLD = Colors.CYAN = Colors.YELLOW = Colors.RED = Colors.RESET = ""
67+
68+
69+
class Severity(IntEnum):
70+
WARNING = 1
71+
SEVERE = 2
72+
73+
74+
@dataclass(frozen=True)
75+
class MessageContext:
76+
ts_file: Path
77+
line: int
78+
lang: str
79+
source: str
80+
translation: str
81+
tr_type: str
82+
excerpt: str
83+
84+
85+
@dataclass(frozen=True)
86+
class WarningItem:
87+
ts_file: Path
88+
line: int
89+
lang: str
90+
message: str
91+
severity: Severity
92+
93+
94+
class MessageLocator(xml.sax.ContentHandler):
95+
"""SAX handler to find exact line numbers of <message> elements."""
96+
97+
def __init__(self):
98+
super().__init__()
99+
self.lines = []
100+
self.locator = None
101+
102+
def setDocumentLocator(self, locator):
103+
self.locator = locator
104+
105+
def startElement(self, name, attrs):
106+
if name == "message" and self.locator:
107+
self.lines.append(self.locator.getLineNumber())
108+
109+
110+
def get_exact_message_lines(text: str):
111+
"""Yield exact line numbers for <message> elements using a SAX parser."""
112+
handler = MessageLocator()
113+
try:
114+
xml.sax.parseString(text.encode("utf-8"), handler)
115+
except xml.sax.SAXException:
116+
pass
117+
yield from handler.lines
118+
119+
120+
def check_language_header(ts_file: Path, root, file_lang: str):
121+
header_lang = root.attrib.get("language", "")
122+
if header_lang != file_lang:
123+
msg = f"Language header mismatch '{header_lang}' != '{file_lang}'"
124+
return [WarningItem(ts_file, 0, file_lang, msg, Severity.WARNING)]
125+
return []
126+
127+
128+
def check_empty_translation(ctx: MessageContext):
129+
if not ctx.translation.strip() and ctx.tr_type != "unfinished":
130+
msg = f"Empty translation for '{ctx.excerpt}'"
131+
return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, msg, Severity.SEVERE)]
132+
return []
133+
134+
135+
def check_placeholders(ctx: MessageContext):
136+
if ctx.tr_type == "unfinished":
137+
return []
138+
src_cnt = Counter(PLACEHOLDER_RE.findall(ctx.source))
139+
tr_cnt = Counter(PLACEHOLDER_RE.findall(ctx.translation))
140+
if src_cnt != tr_cnt:
141+
msg = (f"Placeholder mismatch for '{ctx.excerpt}'\n"
142+
f"Source: {ctx.source}\nTrans: {ctx.translation}")
143+
return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, msg, Severity.WARNING)]
144+
return []
145+
146+
147+
def check_html(ctx: MessageContext):
148+
if (HTML_TAG_RE.search(ctx.source) and not HTML_TAG_RE.search(ctx.translation)
149+
and ctx.tr_type != "unfinished"):
150+
msg = (f"HTML missing for '{ctx.excerpt}'\n"
151+
f"Source: {ctx.source}\nTrans: {ctx.translation}")
152+
return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, msg, Severity.WARNING)]
153+
return []
154+
155+
156+
def check_whitespace(ctx: MessageContext):
157+
if not ctx.translation or ctx.tr_type == "unfinished":
158+
return []
159+
src_lead = ctx.source != ctx.source.lstrip()
160+
src_trail = ctx.source != ctx.source.rstrip()
161+
tr_lead = ctx.translation != ctx.translation.lstrip()
162+
tr_trail = ctx.translation != ctx.translation.rstrip()
163+
if src_lead != tr_lead or src_trail != tr_trail:
164+
msg = f"Leading/trailing whitespace mismatch for '{ctx.excerpt}'"
165+
return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, msg, Severity.WARNING)]
166+
return []
167+
168+
169+
def check_newline_consistency(ctx: MessageContext):
170+
if ctx.source.endswith("\n") != ctx.translation.endswith("\n"):
171+
msg = f"Newline mismatch for '{ctx.excerpt}'"
172+
return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, msg, Severity.WARNING)]
173+
return []
174+
175+
176+
def _extract_message_data(message):
177+
src_node = message.find("source")
178+
source = "".join(src_node.itertext()) if src_node is not None else ""
179+
tr_elem = message.find("translation")
180+
tr_type, translation = "", ""
181+
if tr_elem is not None:
182+
tr_type = tr_elem.attrib.get("type", "")
183+
forms = tr_elem.findall("numerusform")
184+
if forms:
185+
translation = " ".join("".join(n.itertext()) for n in forms)
186+
else:
187+
translation = "".join(tr_elem.itertext())
188+
return source, translation, tr_type
189+
190+
191+
def _process_context(ts_file, file_lang, context, line_gen):
192+
warnings = []
193+
for message in context.findall("message"):
194+
line = next(line_gen, 0)
195+
src, trans, tr_type = _extract_message_data(message)
196+
clean = src.strip().replace("\n", " ")
197+
excerpt = clean[:30] + ("..." if len(clean) > 30 else "")
198+
ctx = MessageContext(ts_file, line, file_lang, src, trans, tr_type, excerpt)
199+
if ctx.tr_type not in {"vanished", "obsolete"}:
200+
for check in [check_empty_translation, check_placeholders, check_html,
201+
check_whitespace, check_newline_consistency]:
202+
warnings.extend(check(ctx))
203+
204+
return warnings
205+
206+
207+
def detect_warnings(ts_file: Path, file_lang: str):
208+
try:
209+
text = ts_file.read_text(encoding="utf-8")
210+
root = ET.fromstring(text)
211+
except (OSError, ET.ParseError) as exc:
212+
return [WarningItem(ts_file, 0, file_lang, f"Error parsing XML: {exc}", Severity.SEVERE)]
213+
214+
warnings = check_language_header(ts_file, root, file_lang)
215+
line_gen = get_exact_message_lines(text)
216+
for context in root.findall("context"):
217+
warnings.extend(_process_context(ts_file, file_lang, context, line_gen))
218+
return warnings
219+
220+
221+
def _print_results(grouped):
222+
for file in sorted(grouped.keys()):
223+
print(f"\n{Colors.BOLD}File: {file.name}{Colors.RESET}")
224+
for w in sorted(grouped[file], key=lambda x: x.line):
225+
is_severe = w.severity == Severity.SEVERE
226+
color = Colors.RED if is_severe else Colors.YELLOW
227+
sev = "SEVERE " if is_severe else "WARNING"
228+
229+
lines = w.message.split("\n")
230+
prefix = f" {Colors.CYAN}Line {w.line:<4}{Colors.RESET}"
231+
232+
print(f"{prefix} | {color}{sev}{Colors.RESET} | {lines[0]}")
233+
for extra in lines[1:]:
234+
print(f" | | {extra}")
235+
236+
237+
def main():
238+
parser = argparse.ArgumentParser()
239+
parser.add_argument("--ts-dir", type=Path, default=Path("../src/translation"))
240+
parser.add_argument("--strict", action="store_true")
241+
parser.add_argument("--color", choices=["never", "auto", "always"], default="auto",
242+
help=("Control color output. 'auto' (default) "
243+
"uses colors only if output is a TTY."))
244+
args = parser.parse_args()
245+
246+
configure_colors(args.color)
247+
248+
ts_files = sorted(args.ts_dir.glob("translation_*.ts"))
249+
if not ts_files:
250+
print("Error: No translation files found in the specified directory.", file=sys.stderr)
251+
return 2
252+
253+
all_warnings, stats = [], defaultdict(lambda: {"severe": 0, "warning": 0})
254+
for f in ts_files:
255+
all_warnings.extend(detect_warnings(f, f.stem.replace("translation_", "")))
256+
257+
grouped = defaultdict(list)
258+
for w in all_warnings:
259+
grouped[w.ts_file].append(w)
260+
stats[w.lang]["severe" if w.severity == Severity.SEVERE else "warning"] += 1
261+
262+
_print_results(grouped)
263+
264+
print("\n== Test Summary ==")
265+
for lang in sorted(stats.keys()):
266+
print(f"{Colors.BOLD}[{lang}]{Colors.RESET} Severe: {stats[lang]['severe']}, "
267+
f"Warnings: {stats[lang]['warning']}")
268+
269+
if sum(s["severe"] for s in stats.values()) > 0 or (
270+
args.strict and sum(s["warning"] for s in stats.values()) > 0):
271+
return 1
272+
return 0
273+
274+
275+
if __name__ == "__main__":
276+
sys.exit(main())

0 commit comments

Comments
 (0)