mirror of
https://github.com/stenzek/duckstation.git
synced 2025-07-20 09:00: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