"""Synchronization of i18n keys with locale files.""" from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional @dataclass class SyncChange: """Represents a single sync change.""" locale: str key: str old_value: Optional[Any] = None new_value: Any = None action: str = "add" @dataclass class SyncResult: """Result of a sync operation.""" changes: List[SyncChange] = field(default_factory=list) locales_updated: List[str] = field(default_factory=list) @property def total_changes(self) -> int: """Total number of changes.""" return len(self.changes) class Syncer: """Synchronize i18n keys with locale files.""" def __init__(self, locale_parser): """Initialize the syncer. Args: locale_parser: LocaleParser instance. """ self.parser = locale_parser def sync_keys( self, keys: set, locale_dir: str, target_locales: Optional[List[str]] = None, placeholder_value: str = "TODO: Translate this string", fill_from_locale: Optional[str] = None, dry_run: bool = False, ) -> List[SyncChange]: """Sync missing keys to locale files. Args: keys: Set of keys extracted from source code. locale_dir: Directory containing locale files. target_locales: Specific locales to sync (None for all). placeholder_value: Value to use for new keys. fill_from_locale: Locale to copy values from. dry_run: If True, don't actually modify files. Returns: List of SyncChange objects. """ locale_dir_path = Path(locale_dir) locale_keys = self.parser.parse_directory(locale_dir, target_locales) changes = [] if fill_from_locale: source_data = self._get_locale_data(locale_dir, fill_from_locale) else: source_data = None locales_to_update = set(locale_keys.keys()) if locale_keys else set() if target_locales: locales_to_update = set(target_locales) & locales_to_update for locale in locales_to_update: existing_keys = locale_keys.get(locale, set()) missing_keys = keys - existing_keys if missing_keys: locale_file = locale_dir_path / f"{locale}.json" if not locale_file.exists(): locale_file = locale_dir_path / f"{locale}.yaml" if locale_file.exists(): data = self.parser.parse_file(locale_file) else: data = {} for key in sorted(missing_keys): if source_data: new_value = self.parser.get_value(source_data, key) if new_value is None: new_value = placeholder_value else: new_value = placeholder_value old_value = self.parser.get_value(data, key) change = SyncChange( locale=locale, key=key, old_value=old_value, new_value=new_value, action="add" if old_value is None else "update", ) changes.append(change) if not dry_run: self.parser.add_key(data, key, new_value) if not dry_run: format = "yaml" if locale_file.suffix in [".yaml", ".yml"] else "json" self.parser.write_file(locale_file, data, format) return changes def _get_locale_data(self, locale_dir: str, locale: str) -> Dict[str, Any]: """Get data for a specific locale. Args: locale_dir: Directory containing locale files. locale: Locale name. Returns: Dictionary of locale data. """ locale_dir_path = Path(locale_dir) locale_file = locale_dir_path / f"{locale}.json" if not locale_file.exists(): locale_file = locale_dir_path / f"{locale}.yaml" if locale_file.exists(): return self.parser.parse_file(locale_file) return {} def sync_to_new_locale( self, keys: set, locale_dir: str, new_locale: str, source_locale: Optional[str] = None, placeholder_value: str = "TODO: Translate this string", dry_run: bool = False, ) -> List[SyncChange]: """Create a new locale file with the given keys. Args: keys: Set of keys to include. locale_dir: Directory for locale files. new_locale: Name of the new locale. source_locale: Locale to copy values from. placeholder_value: Default value for missing translations. dry_run: If True, don't actually create files. Returns: List of SyncChange objects. """ changes = [] data: Dict[str, Any] = {} if source_locale: source_data = self._get_locale_data(locale_dir, source_locale) else: source_data = None for key in sorted(keys): if source_data: value = self.parser.get_value(source_data, key) if value is None: value = placeholder_value else: value = placeholder_value change = SyncChange( locale=new_locale, key=key, old_value=None, new_value=value, action="add", ) changes.append(change) if not dry_run: self.parser.add_key(data, key, value) if not dry_run and changes: locale_file = Path(locale_dir) / f"{new_locale}.json" self.parser.write_file(locale_file, data, "json") return changes def remove_unused_keys( self, locale_keys: Dict[str, set], used_keys: set, locale_dir: str, dry_run: bool = False, ) -> List[SyncChange]: """Remove keys from locale files that are not used in code. Args: locale_keys: Dictionary mapping locales to their key sets. used_keys: Set of keys used in source code. locale_dir: Directory containing locale files. dry_run: If True, don't actually modify files. Returns: List of SyncChange objects. """ changes = [] locale_dir_path = Path(locale_dir) for locale, keys in locale_keys.items(): unused_keys = keys - used_keys if not unused_keys: continue locale_file = locale_dir_path / f"{locale}.json" if not locale_file.exists(): locale_file = locale_dir_path / f"{locale}.yaml" if locale_file.exists(): data = self.parser.parse_file(locale_file) for key in sorted(unused_keys): old_value = self.parser.get_value(data, key) if old_value is not None: change = SyncChange( locale=locale, key=key, old_value=old_value, new_value=None, action="remove", ) changes.append(change) if not dry_run: self._remove_key_from_data(data, key) if not dry_run and changes: format = "yaml" if locale_file.suffix in [".yaml", ".yml"] else "json" self.parser.write_file(locale_file, data, format) return changes def _remove_key_from_data(self, data: Dict[str, Any], key: str) -> None: """Remove a key from nested data structure. Args: data: Nested dictionary. key: Key in dot notation. """ parts = key.split(".") current = data for part in parts[:-1]: if part in current: current = current[part] else: return if parts[-1] in current: del current[parts[-1]]