diff --git a/.github/workflows/translation-check.yml b/.github/workflows/translation-check.yml index ed8fab7f19..7ae834dc5b 100644 --- a/.github/workflows/translation-check.yml +++ b/.github/workflows/translation-check.yml @@ -5,13 +5,17 @@ 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/**" - - "tools/check-wininstaller-translations.sh" - - ".github/workflows/translation-check.yml" + - '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: translation-check: @@ -26,3 +30,5 @@ jobs: run: ./tools/check-wininstaller-translations.sh - name: "Check for duplicate hotkeys (will not fail)" run: perl ./tools/checkkeys.pl + - name: "Check application translations" + run: ./tools/check-translations.py --ts-dir src/translation diff --git a/tools/check-translations.py b/tools/check-translations.py new file mode 100755 index 0000000000..52ab5144ba --- /dev/null +++ b/tools/check-translations.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +# +############################################################################## +# Copyright (c) 2026 +# +# Author(s): +# ChatGPT +# ann0see +# JaminShanti +# Gemini +# 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, Counter +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 + lang: str + 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(" elements) + tr_elem = message.find("translation") + translation = "" + tr_type = "" + if tr_elem is not None: + 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()) + + # 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) + + # All checks + warnings.extend(check_empty_translation(ctx)) + warnings.extend(check_placeholders(ctx)) + warnings.extend(check_html(ctx)) + warnings.extend(check_whitespace(ctx)) + warnings.extend(check_newline_consistency(ctx)) + + return warnings + + +# 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"), + help="Directory containing translation_*.ts files") + parser.add_argument("--strict", action="store_true", + help="Exit non-zero if any warning is found") + args = parser.parse_args() + + 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: + lang = ts_file.stem.replace("translation_", "") + 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].append(w) + + if w.severity == Severity.SEVERE: + failures_by_language[w.lang]["severe"] += 1 + else: + failures_by_language[w.lang]["warning"] += 1 + + # 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 + 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 ==") + for lang in sorted(failures_by_language.keys()): + counts = failures_by_language[lang] + print(f"{BOLD}[{lang}]{RESET} Severe: {counts['severe']}, Warnings: {counts['warning']}") + + total_severe = sum(f["severe"] for f in failures_by_language.values()) + total_warning = sum(f["warning"] for f in failures_by_language.values()) + print(f"\nTotal Severe: {total_severe}, Total Warnings: {total_warning}") + + if total_severe > 0 or (args.strict and total_warning > 0): + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())