mirror of
https://github.com/stenzek/duckstation.git
synced 2025-07-21 01:20:07 +00:00
CI: Check placeholders in translation strings
This commit is contained in:
parent
9442ba74af
commit
9e15fe176c
30
.github/workflows/translation-lint.yml
vendored
Normal file
30
.github/workflows/translation-lint.yml
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
name: Translation Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'src/duckstation-qt/translations/*.ts'
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- 'src/duckstation-qt/translations/*.ts'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
translation-lint:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Meh, can't be bothered to work out exactly which one was modified, just check them all.
|
||||||
|
- name: Check Translation Placeholders
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
for i in src/duckstation-qt/translations/*.ts; do
|
||||||
|
python scripts/verify_translation_placeholders.py "$i";
|
||||||
|
done
|
181
scripts/verify_translation_placeholders.py
Normal file
181
scripts/verify_translation_placeholders.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Qt Translation Placeholder Verifier
|
||||||
|
|
||||||
|
This script verifies that placeholders in Qt translation files are consistent
|
||||||
|
between source and translation strings.
|
||||||
|
|
||||||
|
Placeholder rules:
|
||||||
|
- {} placeholders: translation must have same total count as source
|
||||||
|
- {n} placeholders: translation must use same numbers as source (can repeat)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import List, Set, Tuple, Dict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def extract_placeholders(text: str) -> Tuple[int, Set[int]]:
|
||||||
|
"""
|
||||||
|
Extract placeholder information from a string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (unnamed_count, set_of_numbered_placeholders)
|
||||||
|
"""
|
||||||
|
# Find all placeholders
|
||||||
|
placeholders = re.findall(r'\{(\d*)\}', text)
|
||||||
|
|
||||||
|
unnamed_count = 0
|
||||||
|
numbered_set = set()
|
||||||
|
|
||||||
|
for placeholder in placeholders:
|
||||||
|
if placeholder == '':
|
||||||
|
unnamed_count += 1
|
||||||
|
else:
|
||||||
|
numbered_set.add(int(placeholder))
|
||||||
|
|
||||||
|
return unnamed_count, numbered_set
|
||||||
|
|
||||||
|
|
||||||
|
def verify_placeholders(source: str, translation: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Verify that placeholders in source and translation are consistent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not translation.strip():
|
||||||
|
return True, "" # Empty translations are valid
|
||||||
|
|
||||||
|
source_unnamed, source_numbered = extract_placeholders(source)
|
||||||
|
trans_unnamed, trans_numbered = extract_placeholders(translation)
|
||||||
|
|
||||||
|
# Check if mixing numbered and unnumbered placeholders
|
||||||
|
if source_unnamed > 0 and len(source_numbered) > 0:
|
||||||
|
return False, "Source mixes numbered and unnumbered placeholders"
|
||||||
|
|
||||||
|
if trans_unnamed > 0 and len(trans_numbered) > 0:
|
||||||
|
return False, "Translation mixes numbered and unnumbered placeholders"
|
||||||
|
|
||||||
|
# If source uses unnumbered placeholders
|
||||||
|
if source_unnamed > 0:
|
||||||
|
# Translation can use either unnumbered or numbered placeholders
|
||||||
|
if trans_unnamed > 0:
|
||||||
|
# Both use unnumbered - simple count match
|
||||||
|
if source_unnamed != trans_unnamed:
|
||||||
|
return False, f"Placeholder count mismatch: source has {source_unnamed}, translation has {trans_unnamed}"
|
||||||
|
elif len(trans_numbered) > 0:
|
||||||
|
# Source uses unnumbered, translation uses numbered
|
||||||
|
# Check that numbered placeholders are 0-based and consecutive up to source count
|
||||||
|
expected_numbers = set(range(source_unnamed))
|
||||||
|
if trans_numbered != expected_numbers:
|
||||||
|
if max(trans_numbered) >= source_unnamed:
|
||||||
|
return False, f"Numbered placeholders exceed source count: translation uses {{{max(trans_numbered)}}}, but source only has {source_unnamed} placeholders"
|
||||||
|
elif min(trans_numbered) != 0:
|
||||||
|
return False, f"Numbered placeholders must start from 0: found {{{min(trans_numbered)}}}"
|
||||||
|
else:
|
||||||
|
missing = expected_numbers - trans_numbered
|
||||||
|
return False, f"Missing numbered placeholders: {{{','.join(map(str, sorted(missing)))}}}"
|
||||||
|
|
||||||
|
# If source uses numbered placeholders
|
||||||
|
elif len(source_numbered) > 0:
|
||||||
|
if trans_unnamed > 0:
|
||||||
|
return False, "Source uses numbered {n} but translation uses unnumbered {}"
|
||||||
|
if source_numbered != trans_numbered:
|
||||||
|
missing = source_numbered - trans_numbered
|
||||||
|
extra = trans_numbered - source_numbered
|
||||||
|
error_parts = []
|
||||||
|
if missing:
|
||||||
|
error_parts.append(f"missing {{{','.join(map(str, sorted(missing)))}}}")
|
||||||
|
if extra:
|
||||||
|
error_parts.append(f"extra {{{','.join(map(str, sorted(extra)))}}}")
|
||||||
|
return False, f"Numbered placeholder mismatch: {', '.join(error_parts)}"
|
||||||
|
|
||||||
|
# If translation has placeholders but source doesn't
|
||||||
|
elif trans_unnamed > 0 or len(trans_numbered) > 0:
|
||||||
|
return False, "Translation has placeholders but source doesn't"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def verify_translation_file(file_path: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Verify all translations in a Qt translation file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of error dictionaries with details about invalid translations
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tree = ET.parse(file_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
except ET.ParseError as e:
|
||||||
|
return [{"error": f"XML parsing error: {e}", "line": None}]
|
||||||
|
except FileNotFoundError:
|
||||||
|
return [{"error": f"File not found: {file_path}", "line": None}]
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Find all message elements
|
||||||
|
for message in root.findall('.//message'):
|
||||||
|
source_elem = message.find('source')
|
||||||
|
translation_elem = message.find('translation')
|
||||||
|
location_elem = message.find('location')
|
||||||
|
|
||||||
|
if source_elem is None or translation_elem is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_text = source_elem.text or ""
|
||||||
|
translation_text = translation_elem.text or ""
|
||||||
|
|
||||||
|
is_valid, error_msg = verify_placeholders(source_text, translation_text)
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
error_info = {
|
||||||
|
"source": source_text,
|
||||||
|
"translation": translation_text,
|
||||||
|
"error": error_msg,
|
||||||
|
"line": location_elem.get('line') if location_elem is not None else None,
|
||||||
|
"filename": location_elem.get('filename') if location_elem is not None else None
|
||||||
|
}
|
||||||
|
errors.append(error_info)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <translation_file.ts>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
file_path = sys.argv[1]
|
||||||
|
|
||||||
|
if not Path(file_path).exists():
|
||||||
|
print(f"Error: File '{file_path}' not found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Verifying placeholders in: {file_path}")
|
||||||
|
|
||||||
|
errors = verify_translation_file(file_path)
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
print("All placeholders are valid.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"Found {len(errors)} placeholder errors:")
|
||||||
|
|
||||||
|
for i, error in enumerate(errors, 1):
|
||||||
|
print(f"\nError {i}:")
|
||||||
|
if error.get('filename') and error.get('line'):
|
||||||
|
print(f" Location: {error['filename']}:{error['line']}")
|
||||||
|
print(f" Source: {error['source']}")
|
||||||
|
print(f" Translation: {error['translation']}")
|
||||||
|
print(f" Issue: {error['error']}")
|
||||||
|
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
Loading…
x
Reference in New Issue
Block a user