Files
Qortal-Hub/.agents/skills/i18n/i18n_apply_translations.py
2026-08-10 10:39:09 +02:00

188 lines
6.2 KiB
Python

"""Apply reviewed translations to the locale files, in one batch.
Counterpart to i18n_add_keys.py. That script seeds new keys into every locale
with English placeholders; this one replaces those placeholders with the real
translations once they have been written and reviewed.
It only touches the keys named in the input, never rewriting a whole namespace,
so existing reviewed translations are never clobbered.
Usage:
python3 .agents/skills/i18n/i18n_apply_translations.py <translations.json>
python3 .agents/skills/i18n/i18n_apply_translations.py --audit
Input format: namespace -> language -> dotted key -> translated value.
{
"group": {
"de": {
"reticulum.expiry.no_expiry": "kein ablauf",
"reticulum.expiry.maximum": "maximal {{duration}}"
},
"fi": {
"reticulum.expiry.no_expiry": "ei vanhenemista"
}
}
}
Values must keep every {{placeholder}} from the English source -- reordered
freely to suit the target language. That is enforced below; violations abort the
run before anything is written.
Casing is NOT enforced. A value may start uppercase or lowercase (see
docs/i18n_languages.md): lowercase plus a postProcess suits short reusable
labels, while natural capitalization suits full sentences -- and some languages,
German among them, capitalize nouns regardless.
"""
import argparse
import json
import re
import sys
from pathlib import Path
# .agents/skills/i18n/<this file> -> repo root
def _find_repo_root(start: Path) -> Path:
for parent in [start, *start.parents]:
if (parent / 'src' / 'i18n' / 'locales').is_dir():
return parent
return start.parents[2]
REPO_ROOT = _find_repo_root(Path(__file__).resolve().parent)
LOCALES = REPO_ROOT / 'src' / 'i18n' / 'locales'
PLACEHOLDER = re.compile(r'{{(\w+)}}')
# Terms that legitimately read the same in English and the target language, so
# the audit does not flag them as untranslated.
COGNATE_ALLOWLIST = {'message', 'microphone', 'maximum', 'level {{level}}'}
def sort_node(node):
"""Recursively sort dict keys — locale files are kept alphabetical at every
level so keys are findable by eye and appends do not collide. See
.agents/skills/i18n/i18n_sort.py."""
if isinstance(node, dict):
return {k: sort_node(node[k]) for k in sorted(node, key=str.lower)}
if isinstance(node, list):
return [sort_node(item) for item in node]
return node
def flatten(node, prefix=''):
flat = {}
for key, value in node.items():
full = f'{prefix}.{key}' if prefix else key
if isinstance(value, dict):
flat.update(flatten(value, full))
else:
flat[full] = value
return flat
def put(node, dotted, value):
parts = dotted.split('.')
for part in parts[:-1]:
node = node.setdefault(part, {})
node[parts[-1]] = value
def apply_translations(path_arg):
payload = json.loads(Path(path_arg).read_text(encoding='utf-8'))
problems = []
for namespace, by_lang in payload.items():
english = flatten(
json.loads(
(LOCALES / 'en' / f'{namespace}.json').read_text(encoding='utf-8')
)
)
for lang, entries in by_lang.items():
for key, value in entries.items():
if key not in english:
problems.append(f'{lang}/{namespace}: unknown key {key}')
continue
expected = set(PLACEHOLDER.findall(english[key]))
actual = set(PLACEHOLDER.findall(value))
if expected != actual:
problems.append(
f'{lang}/{namespace}:{key} placeholder mismatch '
f'{sorted(expected)} -> {sorted(actual)}'
)
if not str(value).strip():
problems.append(f'{lang}/{namespace}:{key} is empty')
if problems:
print('Refusing to write, fix these first:\n')
print('\n'.join(f' {p}' for p in problems))
sys.exit(1)
written = 0
for namespace, by_lang in payload.items():
for lang, entries in by_lang.items():
path = LOCALES / lang / f'{namespace}.json'
data = json.loads(path.read_text(encoding='utf-8'))
for key, value in entries.items():
put(data, key, value)
path.write_text(
json.dumps(sort_node(data), ensure_ascii=False, indent=2) + '\n',
encoding='utf-8',
)
written += len(entries)
print(f' {lang}/{namespace}.json: {len(entries)} keys')
print(f'\n{written} translations applied')
def audit():
"""Report values still identical to English in a non-English locale."""
total = 0
for path in sorted(LOCALES.glob('*/*.json')):
lang = path.parent.name
if lang == 'en':
continue
english = flatten(
json.loads((LOCALES / 'en' / path.name).read_text(encoding='utf-8'))
)
same = [
key
for key, value in flatten(
json.loads(path.read_text(encoding='utf-8'))
).items()
if key in english
and value == english[key]
and len(str(value)) > 3
and str(value) not in COGNATE_ALLOWLIST
]
if same:
total += len(same)
print(f'{lang}/{path.name}: {len(same)} untranslated')
print(
f'\n{total} values still match English.'
if total
else '\nNo untranslated values found.'
)
print('Note: genuine cognates may appear here; confirm before "fixing" them.')
def main():
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
parser.add_argument('translations', nargs='?', help='JSON file of translations')
parser.add_argument(
'--audit',
action='store_true',
help='list values still identical to English instead of writing',
)
args = parser.parse_args()
if args.audit:
audit()
elif args.translations:
apply_translations(args.translations)
else:
parser.error('provide a translations file or --audit')
if __name__ == '__main__':
main()