This commit is contained in:
273
i18n_key_sync/sync.py
Normal file
273
i18n_key_sync/sync.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""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]]
|
||||
Reference in New Issue
Block a user