"""Merge new translation keys into every locale file. Adds a nested patch of keys to all 12 locales under src/i18n/locales/ so no locale is ever left with a missing key. Existing values are never overwritten, so this is safe to re-run. The English text is written into every locale as a temporary placeholder. Those placeholders must then be translated -- see i18n_apply_translations.py. A key still holding English in a non-English locale is unfinished work, not a valid default. Usage: python3 .agents/skills/i18n/i18n_add_keys.py python3 .agents/skills/i18n/i18n_add_keys.py group /tmp/new-chat-keys.json The patch file mirrors the locale file structure, with values in English: { "reticulum": { "expiry": { "no_expiry": "no expiry", "maximum": "maximum {{duration}}" } } } """ import argparse import json import sys from pathlib import Path # .agents/skills/i18n/ -> 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' LANGS = ['ar', 'de', 'en', 'es', 'et', 'fi', 'fr', 'it', 'ja', 'pt', 'ru', 'zh'] NAMESPACES = ['auth', 'core', 'group', 'node', 'question', 'reticulum'] 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 merge(dst, src, added, path=''): """Deep-merge src into dst without overwriting. Records added key paths.""" for key, value in src.items(): full = f'{path}.{key}' if path else key if isinstance(value, dict): if not isinstance(dst.get(key), dict): dst[key] = {} merge(dst[key], value, added, full) elif key not in dst: dst[key] = value added.append(full) def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) parser.add_argument('namespace', choices=NAMESPACES) parser.add_argument('patch', help='JSON file of new keys, values in English') parser.add_argument( '--dry-run', action='store_true', help='report what would change without writing', ) args = parser.parse_args() patch = json.loads(Path(args.patch).read_text(encoding='utf-8')) added_en = [] for lang in LANGS: path = LOCALES / lang / f'{args.namespace}.json' if not path.exists(): sys.exit(f'missing locale file: {path}') data = json.loads(path.read_text(encoding='utf-8')) added = [] merge(data, patch, added) if lang == 'en': added_en = added if added and not args.dry_run: path.write_text( json.dumps(sort_node(data), ensure_ascii=False, indent=2) + '\n', encoding='utf-8', ) for key in added_en: print(f' + {args.namespace}:{key}') verb = 'would be added' if args.dry_run else 'added' print(f'\n{len(added_en)} keys {verb} to {args.namespace}.json across {len(LANGS)} locales') if added_en and not args.dry_run: print('Next: translate them with i18n_apply_translations.py') if __name__ == '__main__': main()