diff --git a/.github/workflows/translation-check.yml b/.github/workflows/translation-check.yml
index ed8fab7f19..f1ff5bced8 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"
- "tools/check-wininstaller-translations.sh"
+ - "tools/check-translations.py"
- ".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'
+ - '.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..d9c2dd02d0
--- /dev/null
+++ b/tools/check-translations.py
@@ -0,0 +1,274 @@
+#!/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 Affero General Public License as published by the Free
+# Software Foundation, either version 3 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 Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+
+"""
+Qt TS translation checker.
+
+This tool validates Qt `.ts` translation files according to Qt Linguist
+semantics.
+"""
+
+import argparse
+import re
+import sys
+import xml.etree.ElementTree as ET
+import xml.sax
+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"<[^>]+>")
+
+
+class Colors:
+ """ANSI escape codes for terminal output."""
+ BOLD = "\033[1m"
+ CYAN = "\033[36m"
+ YELLOW = "\033[33m"
+ RED = "\033[31m"
+ RESET = "\033[0m"
+
+
+def configure_colors(color_arg: str):
+ """Disable color constants if outputting to a non-TTY without 'always' override."""
+ if color_arg == 'never' or (color_arg == 'auto' and not sys.stdout.isatty()):
+ Colors.BOLD = Colors.CYAN = Colors.YELLOW = Colors.RED = Colors.RESET = ""
+
+
+class Severity(IntEnum):
+ WARNING = 1
+ SEVERE = 2
+
+
+@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
+
+
+class MessageLocator(xml.sax.ContentHandler):
+ """SAX handler to find exact line numbers of elements."""
+
+ def __init__(self):
+ super().__init__()
+ self.lines = []
+ self.locator = None
+
+ def setDocumentLocator(self, locator):
+ self.locator = locator
+
+ def startElement(self, name, attrs):
+ if name == "message" and self.locator:
+ self.lines.append(self.locator.getLineNumber())
+
+
+def get_exact_message_lines(text: str):
+ """Yield exact line numbers for elements using a SAX parser."""
+ handler = MessageLocator()
+ try:
+ xml.sax.parseString(text.encode("utf-8"), handler)
+ except xml.sax.SAXException:
+ pass
+ yield from handler.lines
+
+
+def check_language_header(ts_file: Path, root, file_lang: str):
+ header_lang = root.attrib.get("language", "")
+ if header_lang != file_lang:
+ msg = f"Language header mismatch '{header_lang}' != '{file_lang}'"
+ return [WarningItem(ts_file, 0, file_lang, msg, Severity.WARNING)]
+ return []
+
+
+def check_empty_translation(ctx: MessageContext):
+ if not ctx.translation.strip() and ctx.tr_type != "unfinished":
+ msg = f"Empty translation for '{ctx.excerpt}'"
+ return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, msg, Severity.SEVERE)]
+ return []
+
+
+def check_placeholders(ctx: MessageContext):
+ if ctx.tr_type == "unfinished":
+ return []
+ src_cnt = Counter(PLACEHOLDER_RE.findall(ctx.source))
+ tr_cnt = Counter(PLACEHOLDER_RE.findall(ctx.translation))
+ if src_cnt != tr_cnt:
+ msg = (f"Placeholder mismatch for '{ctx.excerpt}'\n"
+ f"Source: {ctx.source}\nTrans: {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"):
+ msg = (f"HTML missing for '{ctx.excerpt}'\n"
+ f"Source: {ctx.source}\nTrans: {ctx.translation}")
+ return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, msg, Severity.WARNING)]
+ return []
+
+
+def check_whitespace(ctx: MessageContext):
+ if not ctx.translation or ctx.tr_type == "unfinished":
+ return []
+ 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:
+ msg = f"Leading/trailing whitespace mismatch for '{ctx.excerpt}'"
+ return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, msg, Severity.WARNING)]
+ return []
+
+
+def check_newline_consistency(ctx: MessageContext):
+ if ctx.source.endswith("\n") != ctx.translation.endswith("\n"):
+ msg = f"Newline mismatch for '{ctx.excerpt}'"
+ return [WarningItem(ctx.ts_file, ctx.line, ctx.lang, msg, Severity.WARNING)]
+ return []
+
+
+def _extract_message_data(message):
+ src_node = message.find("source")
+ source = "".join(src_node.itertext()) if src_node is not None else ""
+ tr_elem = message.find("translation")
+ tr_type, translation = "", ""
+ if tr_elem is not None:
+ tr_type = tr_elem.attrib.get("type", "")
+ forms = tr_elem.findall("numerusform")
+ if forms:
+ translation = " ".join("".join(n.itertext()) for n in forms)
+ else:
+ translation = "".join(tr_elem.itertext())
+ return source, translation, tr_type
+
+
+def _process_context(ts_file, file_lang, context, line_gen):
+ warnings = []
+ for message in context.findall("message"):
+ line = next(line_gen, 0)
+ src, trans, tr_type = _extract_message_data(message)
+ clean = src.strip().replace("\n", " ")
+ excerpt = clean[:30] + ("..." if len(clean) > 30 else "")
+ ctx = MessageContext(ts_file, line, file_lang, src, trans, tr_type, excerpt)
+ if ctx.tr_type not in {"vanished", "obsolete"}:
+ for check in [check_empty_translation, check_placeholders, check_html,
+ check_whitespace, check_newline_consistency]:
+ warnings.extend(check(ctx))
+
+ return warnings
+
+
+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, file_lang, f"Error parsing XML: {exc}", Severity.SEVERE)]
+
+ warnings = check_language_header(ts_file, root, file_lang)
+ line_gen = get_exact_message_lines(text)
+ for context in root.findall("context"):
+ warnings.extend(_process_context(ts_file, file_lang, context, line_gen))
+ return warnings
+
+
+def _print_results(grouped):
+ for file in sorted(grouped.keys()):
+ print(f"\n{Colors.BOLD}File: {file.name}{Colors.RESET}")
+ for w in sorted(grouped[file], key=lambda x: x.line):
+ is_severe = w.severity == Severity.SEVERE
+ color = Colors.RED if is_severe else Colors.YELLOW
+ sev = "SEVERE " if is_severe else "WARNING"
+
+ lines = w.message.split("\n")
+ prefix = f" {Colors.CYAN}Line {w.line:<4}{Colors.RESET}"
+
+ print(f"{prefix} | {color}{sev}{Colors.RESET} | {lines[0]}")
+ for extra in lines[1:]:
+ print(f" | | {extra}")
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--ts-dir", type=Path, default=Path("../src/translation"))
+ parser.add_argument("--strict", action="store_true")
+ parser.add_argument("--color", choices=["never", "auto", "always"], default="auto",
+ help=("Control color output. 'auto' (default) "
+ "uses colors only if output is a TTY."))
+ args = parser.parse_args()
+
+ configure_colors(args.color)
+
+ ts_files = sorted(args.ts_dir.glob("translation_*.ts"))
+ if not ts_files:
+ print("Error: No translation files found in the specified directory.", file=sys.stderr)
+ return 2
+
+ all_warnings, stats = [], defaultdict(lambda: {"severe": 0, "warning": 0})
+ for f in ts_files:
+ all_warnings.extend(detect_warnings(f, f.stem.replace("translation_", "")))
+
+ grouped = defaultdict(list)
+ for w in all_warnings:
+ grouped[w.ts_file].append(w)
+ stats[w.lang]["severe" if w.severity == Severity.SEVERE else "warning"] += 1
+
+ _print_results(grouped)
+
+ print("\n== Test Summary ==")
+ for lang in sorted(stats.keys()):
+ print(f"{Colors.BOLD}[{lang}]{Colors.RESET} Severe: {stats[lang]['severe']}, "
+ f"Warnings: {stats[lang]['warning']}")
+
+ if sum(s["severe"] for s in stats.values()) > 0 or (
+ args.strict and sum(s["warning"] for s in stats.values()) > 0):
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())