from __future__ import annotations import json from pathlib import Path from typing import Any from bot.i18n import SUPPORTED_LANGUAGES, translate as legacy_translate class Translator: """Centralized JSON translator with English default and legacy fallback.""" def __init__(self, bot: Any, locales_dir: str = "bot/locales") -> None: self.bot = bot self.locales_dir = Path(locales_dir) self._cache: dict[str, dict[str, str]] = {} self.default_language = "en" self.supported_languages = set(SUPPORTED_LANGUAGES) if SUPPORTED_LANGUAGES else {"en", "ar"} self._load_locales() def _load_locales(self) -> None: # Include locale files that exist on disk, even if legacy i18n list is behind. file_langs = { p.stem for p in self.locales_dir.glob("*.json") if p.is_file() } self.supported_languages = set(self.supported_languages) | file_langs for lang in self.supported_languages: path = self.locales_dir / f"{lang}.json" try: data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} except Exception: data = {} self._cache[lang] = data if isinstance(data, dict) else {} self._merge_english_defaults() @staticmethod def _deep_merge_defaults(defaults: dict[str, Any], target: dict[str, Any]) -> dict[str, Any]: merged: dict[str, Any] = dict(target) for key, value in defaults.items(): if key not in merged: merged[key] = value continue if isinstance(value, dict) and isinstance(merged.get(key), dict): merged[key] = Translator._deep_merge_defaults(value, merged[key]) # type: ignore[arg-type] return merged def _merge_english_defaults(self) -> None: """Guarantee all locale packs have the full key tree using English as baseline.""" en_pack = self._cache.get(self.default_language, {}) if not isinstance(en_pack, dict): return for lang, pack in list(self._cache.items()): if not isinstance(pack, dict): self._cache[lang] = dict(en_pack) continue if lang == self.default_language: continue self._cache[lang] = self._deep_merge_defaults(en_pack, pack) def _lookup_key(self, lang: str, key: str) -> str | None: """Lookup a translation key supporting nested dotted paths.""" data = self._cache.get(lang, {}) if key in data: value = data.get(key) return str(value) if isinstance(value, str) else None cursor: Any = data for part in key.split("."): if not isinstance(cursor, dict) or part not in cursor: return None cursor = cursor[part] return str(cursor) if isinstance(cursor, str) else None async def get(self, key: str, guild_id: int | None = None, **kwargs: object) -> str: lang = await self.bot.get_guild_language(guild_id) if guild_id else self.default_language if lang not in self.supported_languages: lang = self.default_language template = self._lookup_key(lang, key) if template is None: template = self._lookup_key(self.default_language, key) if template is None: # Backward compatibility for existing i18n keys during migration. try: return legacy_translate(lang, key, **kwargs) except Exception: return key try: return str(template).format(**kwargs) except Exception: return str(template)