From f38d03ca624b348fab8b15f9f7660187ad1d2513 Mon Sep 17 00:00:00 2001 From: ann0see <20726856+ann0see@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:53:24 +0100 Subject: [PATCH 1/6] 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 --- .github/workflows/translation-check.yml | 3 + tools/check-translations.py | 238 ++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100755 tools/check-translations.py diff --git a/.github/workflows/translation-check.yml b/.github/workflows/translation-check.yml index 60d8a2d40d..e3e9ccc494 100644 --- a/.github/workflows/translation-check.yml +++ b/.github/workflows/translation-check.yml @@ -10,6 +10,7 @@ on: push: paths: - 'src/translation/wininstaller/**' + - 'src/translation/*.ts' - 'tools/check-wininstaller-translations.sh' - '.github/workflows/translation-check.yml' @@ -24,5 +25,7 @@ jobs: uses: actions/checkout@v6 - name: "Check Windows installer translations" run: ./tools/check-wininstaller-translations.sh + - name: "Check application translations" + run: pip install PyGithub && ./tools/check-translations.py #- name: "Check for duplicate hotkeys (will not fail)" # run: sudo apt install libxml-simple-perl && cd src/translation/ && perl ./tools/checkkeys.pl diff --git a/tools/check-translations.py b/tools/check-translations.py new file mode 100755 index 0000000000..7d60a99fa6 --- /dev/null +++ b/tools/check-translations.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +# +############################################################################## +# Copyright (c) 2026 +# +# Author(s): +# ChatGPT +# ann0see +# The Jamulus Development Team +# +############################################################################## +# +# This program 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. +# +# This program 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 +# this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# +############################################################################## + +""" +Qt TS translation checker. + +This tool validates Qt `.ts` translation files according to Qt Linguist +semantics. +Warnings are reported with best-effort line numbers. In strict mode, the +presence of any warning results in a non-zero exit code to allow CI failure. +""" + +import argparse +import re +import sys +import xml.etree.ElementTree as ET +from collections import defaultdict +from dataclasses import dataclass +from enum import IntEnum +from pathlib import Path + +# Regex helpers +PLACEHOLDER_RE = re.compile(r"%\d+") +HTML_TAG_RE = re.compile(r"<[^>]+>") + +# ANSI escape codes +BOLD = "\033[1m" +CYAN = "\033[36m" +YELLOW = "\033[33m" +RED = "\033[31m" +RESET = "\033[0m" + +# Severity Enum +class Severity(IntEnum): + WARNING = 1 + SEVERE = 2 + +# Data structures +@dataclass(frozen=True) +class MessageContext: + ts_file: Path + line: int + lang: str + source: str + translation: str + tr_type: str + excerpt: str + +@dataclass(frozen=True) +class WarningItem: + ts_file: Path + line: int + message: str + severity: Severity + +# Helpers +def approximate_message_lines(text: str): + """Yield approximate line numbers for elements.""" + lines = text.splitlines() + cursor = 0 + for _ in range(text.count(" 0 or (args.strict and total_warning > 0): + return 1 + + return 0 + +if __name__ == "__main__": + sys.exit(main()) From 4024c00b69afb0abe62d3f6d2345ce2e421ff84b Mon Sep 17 00:00:00 2001 From: "Radjammin@gmail.com" Date: Sat, 9 May 2026 19:00:16 -0400 Subject: [PATCH 2/6] Adding 3 fixes --- tools/check-translations.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tools/check-translations.py b/tools/check-translations.py index 7d60a99fa6..3101567ac0 100755 --- a/tools/check-translations.py +++ b/tools/check-translations.py @@ -39,7 +39,7 @@ import re import sys import xml.etree.ElementTree as ET -from collections import defaultdict +from collections import defaultdict, Counter from dataclasses import dataclass from enum import IntEnum from pathlib import Path @@ -103,14 +103,17 @@ def check_language_header(ts_file: Path, root): return [] def check_empty_translation(ctx: MessageContext): - if not ctx.translation and ctx.tr_type != "unfinished": + # Added .strip() here instead of during string extraction + if not ctx.translation.strip() and ctx.tr_type != "unfinished": return [WarningItem(ctx.ts_file, ctx.line, f"{ctx.lang}: empty translation for '{ctx.excerpt}...'", Severity.SEVERE)] return [] def check_placeholders(ctx: MessageContext): - if ctx.tr_type != "unfinished" and set(PLACEHOLDER_RE.findall(ctx.source)) != set(PLACEHOLDER_RE.findall(ctx.translation)): + # Replaced set() with Counter() to ensure exact placeholder counts match + if ctx.tr_type != "unfinished" and Counter(PLACEHOLDER_RE.findall(ctx.source)) != Counter( + PLACEHOLDER_RE.findall(ctx.translation)): return [WarningItem(ctx.ts_file, ctx.line, f"{ctx.lang}: placeholder mismatch for '{ctx.excerpt}...'\n" f"Source: {ctx.source}\nTranslation: {ctx.translation}", @@ -118,7 +121,8 @@ def check_placeholders(ctx: MessageContext): return [] def check_html(ctx: MessageContext): - if HTML_TAG_RE.search(ctx.source) and not HTML_TAG_RE.search(ctx.translation) and ctx.tr_type != "unfinished": + if HTML_TAG_RE.search(ctx.source) and not HTML_TAG_RE.search( + ctx.translation) and ctx.tr_type != "unfinished": return [WarningItem(ctx.ts_file, ctx.line, f"{ctx.lang}: HTML missing for '{ctx.excerpt}...'\n" f"Source: {ctx.source}\nTranslation: {ctx.translation}", @@ -157,14 +161,18 @@ def detect_warnings(ts_file: Path): for context in root.findall("context"): for message, line in zip(context.findall("message"), message_lines): - source = (message.findtext("source") or "").strip() + # Removed .strip() so whitespace rules trigger correctly + source = message.findtext("source") or "" tr_elem = message.find("translation") translation = "" tr_type = "" if tr_elem is not None: - translation = (tr_elem.text or "").strip() + # Removed .strip() here as well + translation = tr_elem.text or "" tr_type = tr_elem.attrib.get("type", "") - excerpt = source[:30].replace("\n", " ") + + # Excerpt can still be stripped just for cleaner logging + excerpt = source.strip()[:30].replace("\n", " ") ctx = MessageContext(ts_file, line, file_lang, source, translation, tr_type, excerpt) @@ -180,7 +188,8 @@ def detect_warnings(ts_file: Path): # CLI def main(): parser = argparse.ArgumentParser(description="Qt TS translation checker with extended rules") - parser.add_argument("--ts-dir", type=Path, default=Path("src/translation"), + # Updated default path to be more reliable in CI environments + parser.add_argument("--ts-dir", type=Path, default=Path("../src/translation"), help="Directory containing translation_*.ts files") parser.add_argument("--strict", action="store_true", help="Exit non-zero if any warning is found") @@ -209,7 +218,7 @@ def main(): print(f"{BOLD}{file}{RESET} {CYAN}line {line}{RESET}: {color}{w.message}{RESET}") # Test summary - failures_by_language = defaultdict(lambda: {"severe":0, "warning":0}) + failures_by_language = defaultdict(lambda: {"severe": 0, "warning": 0}) all_languages = set() for w in all_warnings: From eedebbbd58b9c6d19e8f4bec8f01e48060a1a9c8 Mon Sep 17 00:00:00 2001 From: "Radjammin@gmail.com" Date: Sat, 9 May 2026 19:06:55 -0400 Subject: [PATCH 3/6] Adding whitespace comparison --- tools/check-translations.py | 82 ++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/tools/check-translations.py b/tools/check-translations.py index 3101567ac0..49e8e7cc6f 100755 --- a/tools/check-translations.py +++ b/tools/check-translations.py @@ -55,11 +55,13 @@ RED = "\033[31m" RESET = "\033[0m" + # Severity Enum class Severity(IntEnum): WARNING = 1 SEVERE = 2 + # Data structures @dataclass(frozen=True) class MessageContext: @@ -71,13 +73,16 @@ class MessageContext: tr_type: str excerpt: str + @dataclass(frozen=True) class WarningItem: ts_file: Path line: int + lang: str message: str severity: Severity + # Helpers def approximate_message_lines(text: str): """Yield approximate line numbers for elements.""" @@ -92,86 +97,95 @@ def approximate_message_lines(text: str): else: yield 0 + # Checks -def check_language_header(ts_file: Path, root): - file_lang = ts_file.stem.replace("translation_", "") +def check_language_header(ts_file: Path, root, file_lang: str): header_lang = root.attrib.get("language", "") if header_lang != file_lang: - return [WarningItem(ts_file, 0, + return [WarningItem(ts_file, 0, file_lang, f"Language header mismatch '{header_lang}' != '{file_lang}'", Severity.WARNING)] return [] + def check_empty_translation(ctx: MessageContext): - # Added .strip() here instead of during string extraction if not ctx.translation.strip() and ctx.tr_type != "unfinished": - return [WarningItem(ctx.ts_file, ctx.line, + return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, f"{ctx.lang}: empty translation for '{ctx.excerpt}...'", Severity.SEVERE)] return [] + def check_placeholders(ctx: MessageContext): - # Replaced set() with Counter() to ensure exact placeholder counts match if ctx.tr_type != "unfinished" and Counter(PLACEHOLDER_RE.findall(ctx.source)) != Counter( PLACEHOLDER_RE.findall(ctx.translation)): - return [WarningItem(ctx.ts_file, ctx.line, + return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, f"{ctx.lang}: placeholder mismatch for '{ctx.excerpt}...'\n" f"Source: {ctx.source}\nTranslation: {ctx.translation}", Severity.WARNING)] return [] + def check_html(ctx: MessageContext): if HTML_TAG_RE.search(ctx.source) and not HTML_TAG_RE.search( ctx.translation) and ctx.tr_type != "unfinished": - return [WarningItem(ctx.ts_file, ctx.line, + return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, f"{ctx.lang}: HTML missing for '{ctx.excerpt}...'\n" f"Source: {ctx.source}\nTranslation: {ctx.translation}", Severity.WARNING)] return [] + def check_whitespace(ctx: MessageContext): - if ctx.source != ctx.source.strip() or ctx.translation != ctx.translation.strip(): - return [WarningItem(ctx.ts_file, ctx.line, - f"{ctx.lang}: leading/trailing whitespace difference for '{ctx.excerpt}...'", + if not ctx.translation or ctx.tr_type == "unfinished": + return [] + + # Check if leading/trailing whitespace presence matches between source and translation + src_lead = ctx.source != ctx.source.lstrip() + src_trail = ctx.source != ctx.source.rstrip() + tr_lead = ctx.translation != ctx.translation.lstrip() + tr_trail = ctx.translation != ctx.translation.rstrip() + + if src_lead != tr_lead or src_trail != tr_trail: + return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, + f"{ctx.lang}: leading/trailing whitespace mismatch for '{ctx.excerpt}...'", Severity.WARNING)] return [] + def check_newline_consistency(ctx: MessageContext): if ctx.source.endswith("\n") != ctx.translation.endswith("\n"): - return [WarningItem(ctx.ts_file, ctx.line, + return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, f"{ctx.lang}: newline mismatch for '{ctx.excerpt}...'", Severity.WARNING)] return [] + # Detect warnings -def detect_warnings(ts_file: Path): +def detect_warnings(ts_file: Path, file_lang: str): try: text = ts_file.read_text(encoding="utf-8") root = ET.fromstring(text) except (OSError, ET.ParseError) as exc: - return [WarningItem(ts_file, 0, + return [WarningItem(ts_file, 0, file_lang, f"Error reading or parsing XML: {exc}", Severity.SEVERE)] warnings = [] - warnings.extend(check_language_header(ts_file, root)) + warnings.extend(check_language_header(ts_file, root, file_lang)) - file_lang = ts_file.stem.replace("translation_", "") message_lines = approximate_message_lines(text) for context in root.findall("context"): for message, line in zip(context.findall("message"), message_lines): - # Removed .strip() so whitespace rules trigger correctly source = message.findtext("source") or "" tr_elem = message.find("translation") translation = "" tr_type = "" if tr_elem is not None: - # Removed .strip() here as well translation = tr_elem.text or "" tr_type = tr_elem.attrib.get("type", "") - # Excerpt can still be stripped just for cleaner logging excerpt = source.strip()[:30].replace("\n", " ") ctx = MessageContext(ts_file, line, file_lang, source, translation, tr_type, excerpt) @@ -185,10 +199,10 @@ def detect_warnings(ts_file: Path): return warnings + # CLI def main(): parser = argparse.ArgumentParser(description="Qt TS translation checker with extended rules") - # Updated default path to be more reliable in CI environments parser.add_argument("--ts-dir", type=Path, default=Path("../src/translation"), help="Directory containing translation_*.ts files") parser.add_argument("--strict", action="store_true", @@ -198,19 +212,31 @@ def main(): if not args.ts_dir.exists(): print(f"Directory not found: {args.ts_dir}", file=sys.stderr) return 2 + ts_files = sorted(args.ts_dir.glob("translation_*.ts")) if not ts_files: print(f"No TS files found in {args.ts_dir}", file=sys.stderr) return 2 all_warnings = [] + failures_by_language = defaultdict(lambda: {"severe": 0, "warning": 0}) + for ts_file in ts_files: - all_warnings.extend(detect_warnings(ts_file)) + lang = ts_file.stem.replace("translation_", "") + failures_by_language[ + lang] # Initializes default counters for this language even if 0 warnings exist + all_warnings.extend(detect_warnings(ts_file, lang)) grouped = defaultdict(list) for w in all_warnings: grouped[(w.ts_file, w.line)].append(w) + # Populate failures summary using the embedded WarningItem lang + if w.severity == Severity.SEVERE: + failures_by_language[w.lang]["severe"] += 1 + else: + failures_by_language[w.lang]["warning"] += 1 + # Detailed output for (file, line), messages in sorted(grouped.items()): for w in messages: @@ -218,19 +244,8 @@ def main(): print(f"{BOLD}{file}{RESET} {CYAN}line {line}{RESET}: {color}{w.message}{RESET}") # Test summary - failures_by_language = defaultdict(lambda: {"severe": 0, "warning": 0}) - all_languages = set() - - for w in all_warnings: - lang = w.ts_file.stem.replace("translation_", "") - all_languages.add(lang) - if w.severity == Severity.SEVERE: - failures_by_language[lang]["severe"] += 1 - else: - failures_by_language[lang]["warning"] += 1 - print("\n== Test Summary ==") - for lang in sorted(all_languages): + for lang in sorted(failures_by_language.keys()): counts = failures_by_language[lang] print(f"{BOLD}[{lang}]{RESET} Severe: {counts['severe']}, Warnings: {counts['warning']}") @@ -243,5 +258,6 @@ def main(): return 0 + if __name__ == "__main__": sys.exit(main()) From 064481cde0032d012ae3dd1b84af6b26fddaa18e Mon Sep 17 00:00:00 2001 From: "Radjammin@gmail.com" Date: Sun, 10 May 2026 16:54:26 -0400 Subject: [PATCH 4/6] Reduced output to bring attention to findings --- tools/check-translations.py | 67 +++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/tools/check-translations.py b/tools/check-translations.py index 49e8e7cc6f..52ab5144ba 100755 --- a/tools/check-translations.py +++ b/tools/check-translations.py @@ -6,6 +6,8 @@ # Author(s): # ChatGPT # ann0see +# JaminShanti +# Gemini # The Jamulus Development Team # ############################################################################## @@ -111,7 +113,7 @@ def check_language_header(ts_file: Path, root, file_lang: str): def check_empty_translation(ctx: MessageContext): if not ctx.translation.strip() and ctx.tr_type != "unfinished": return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, - f"{ctx.lang}: empty translation for '{ctx.excerpt}...'", + f"Empty translation for '{ctx.excerpt}'", Severity.SEVERE)] return [] @@ -119,20 +121,20 @@ def check_empty_translation(ctx: MessageContext): def check_placeholders(ctx: MessageContext): if ctx.tr_type != "unfinished" and Counter(PLACEHOLDER_RE.findall(ctx.source)) != Counter( PLACEHOLDER_RE.findall(ctx.translation)): - return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, - f"{ctx.lang}: placeholder mismatch for '{ctx.excerpt}...'\n" - f"Source: {ctx.source}\nTranslation: {ctx.translation}", - Severity.WARNING)] + msg = (f"Placeholder mismatch for '{ctx.excerpt}'\n" + f"Source: {ctx.source}\n" + f"Trans: {ctx.translation}") + return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, msg, Severity.WARNING)] return [] def check_html(ctx: MessageContext): if HTML_TAG_RE.search(ctx.source) and not HTML_TAG_RE.search( ctx.translation) and ctx.tr_type != "unfinished": - return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, - f"{ctx.lang}: HTML missing for '{ctx.excerpt}...'\n" - f"Source: {ctx.source}\nTranslation: {ctx.translation}", - Severity.WARNING)] + msg = (f"HTML missing for '{ctx.excerpt}'\n" + f"Source: {ctx.source}\n" + f"Trans: {ctx.translation}") + return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, msg, Severity.WARNING)] return [] @@ -148,7 +150,7 @@ def check_whitespace(ctx: MessageContext): if src_lead != tr_lead or src_trail != tr_trail: return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, - f"{ctx.lang}: leading/trailing whitespace mismatch for '{ctx.excerpt}...'", + f"Leading/trailing whitespace mismatch for '{ctx.excerpt}'", Severity.WARNING)] return [] @@ -156,7 +158,7 @@ def check_whitespace(ctx: MessageContext): def check_newline_consistency(ctx: MessageContext): if ctx.source.endswith("\n") != ctx.translation.endswith("\n"): return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, - f"{ctx.lang}: newline mismatch for '{ctx.excerpt}...'", + f"Newline mismatch for '{ctx.excerpt}'", Severity.WARNING)] return [] @@ -178,15 +180,26 @@ def detect_warnings(ts_file: Path, file_lang: str): for context in root.findall("context"): for message, line in zip(context.findall("message"), message_lines): - source = message.findtext("source") or "" + + # Safely extract source text + source_elem = message.find("source") + source = "".join(source_elem.itertext()) if source_elem is not None else "" + + # Safely extract translation text (handling Qt plural elements) tr_elem = message.find("translation") translation = "" tr_type = "" if tr_elem is not None: - translation = tr_elem.text or "" tr_type = tr_elem.attrib.get("type", "") + numerus_forms = tr_elem.findall("numerusform") + if numerus_forms: + translation = " ".join("".join(n.itertext()) for n in numerus_forms) + else: + translation = "".join(tr_elem.itertext()) - excerpt = source.strip()[:30].replace("\n", " ") + # Format a clean excerpt without blindly adding '...' to tiny strings + source_clean = source.strip().replace("\n", " ") + excerpt = source_clean[:30] + ("..." if len(source_clean) > 30 else "") ctx = MessageContext(ts_file, line, file_lang, source, translation, tr_type, excerpt) @@ -223,25 +236,35 @@ def main(): for ts_file in ts_files: lang = ts_file.stem.replace("translation_", "") - failures_by_language[ - lang] # Initializes default counters for this language even if 0 warnings exist + failures_by_language[lang] # Initializes default counters all_warnings.extend(detect_warnings(ts_file, lang)) + # Group output by file grouped = defaultdict(list) for w in all_warnings: - grouped[(w.ts_file, w.line)].append(w) + grouped[w.ts_file].append(w) - # Populate failures summary using the embedded WarningItem lang if w.severity == Severity.SEVERE: failures_by_language[w.lang]["severe"] += 1 else: failures_by_language[w.lang]["warning"] += 1 - # Detailed output - for (file, line), messages in sorted(grouped.items()): - for w in messages: + # Detailed clean column output + for file in sorted(grouped.keys()): + messages = grouped[file] + print(f"\n{BOLD}File: {file.name}{RESET}") + + for w in sorted(messages, key=lambda x: x.line): color = RED if w.severity == Severity.SEVERE else YELLOW - print(f"{BOLD}{file}{RESET} {CYAN}line {line}{RESET}: {color}{w.message}{RESET}") + sev_text = "SEVERE " if w.severity == Severity.SEVERE else "WARNING" + + msg_lines = w.message.split("\n") + # Print the primary warning line + print(f" {CYAN}Line {w.line:<4}{RESET} | {color}{sev_text}{RESET} | {msg_lines[0]}") + + # Print any secondary data (like HTML/Placeholder source & trans contexts) properly aligned + for extra_line in msg_lines[1:]: + print(f" | | {extra_line}") # Test summary print("\n== Test Summary ==") From eca14ceb9519024cb240a7c8243adfb60b7289c4 Mon Sep 17 00:00:00 2001 From: "Radjammin@gmail.com" Date: Sun, 10 May 2026 17:17:00 -0400 Subject: [PATCH 5/6] update to trigger actions --- .github/workflows/translation-check.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/translation-check.yml b/.github/workflows/translation-check.yml index e46a251a20..e05e63f508 100644 --- a/.github/workflows/translation-check.yml +++ b/.github/workflows/translation-check.yml @@ -5,13 +5,16 @@ on: pull_request: paths: - "src/translation/wininstaller/**" + - "src/translation/*.ts" # <-- ADD THIS - "tools/check-wininstaller-translations.sh" + - "tools/check-translations.py" # <-- ADD THIS (Match your script name) - ".github/workflows/translation-check.yml" push: paths: - 'src/translation/wininstaller/**' - 'src/translation/*.ts' - 'tools/check-wininstaller-translations.sh' + - 'tools/check-translations.py' # <-- ADD THIS HERE TOO - '.github/workflows/translation-check.yml' jobs: From b9fad6c76448ae4997e7c8991660cda1d6fd669f Mon Sep 17 00:00:00 2001 From: "Radjammin@gmail.com" Date: Sun, 10 May 2026 17:20:36 -0400 Subject: [PATCH 6/6] update to trigger actions --- .github/workflows/translation-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/translation-check.yml b/.github/workflows/translation-check.yml index e05e63f508..7ae834dc5b 100644 --- a/.github/workflows/translation-check.yml +++ b/.github/workflows/translation-check.yml @@ -31,4 +31,4 @@ jobs: - name: "Check for duplicate hotkeys (will not fail)" run: perl ./tools/checkkeys.pl - name: "Check application translations" - run: ./tools/check-translations.py + run: ./tools/check-translations.py --ts-dir src/translation