#!/usr/bin/env python3 """ Internationalization (i18n) module for MeshCore Bot Provides translation functionality for bot commands and responses """ import json import os from pathlib import Path from typing import Dict, Any, Optional class Translator: """Handles translation loading and lookup for the bot""" def __init__(self, language: str = 'en', translation_path: str = 'translations/'): """ Initialize translator Args: language: Language code (e.g., 'en', 'es', 'es-MX', 'es-ES', 'fr', 'de') Supports locale codes like 'es-MX' for Mexican Spanish or 'es-ES' for Spain Spanish translation_path: Path to translation files directory """ self.language = language self.translation_path = translation_path self.base_language = self._extract_base_language(language) self.translations: Dict[str, Any] = {} self.fallback_translations: Dict[str, Any] = {} self._load_translations() def _extract_base_language(self, language: str) -> str: """ Extract base language code from locale code Args: language: Language code (e.g., 'en', 'es', 'es-MX', 'es-ES') Returns: Base language code (e.g., 'es' from 'es-MX') """ # Handle locale codes like 'es-MX' or 'es_ES' if '-' in language: return language.split('-')[0] elif '_' in language: return language.split('_')[0] return language def _load_translations(self): """Load translation files with locale support""" # Load default (English) first for final fallback self.fallback_translations = self._load_file('en') # Load requested language with locale support if self.language == 'en': self.translations = self.fallback_translations else: # Load base language first (e.g., es.json) base_translations = {} if self.base_language != 'en': base_translations = self._load_file(self.base_language) # Try to load locale-specific file (e.g., es-MX.json) locale_translations = {} if self.base_language != self.language: locale_translations = self._load_file(self.language) # Merge: locale-specific overrides base language, base language overrides English # First merge base into English merged = self._merge_translations(base_translations, self.fallback_translations) # Then merge locale-specific into the merged result self.translations = self._merge_translations(locale_translations, merged) def _merge_translations(self, primary: Dict[str, Any], fallback: Dict[str, Any]) -> Dict[str, Any]: """ Merge primary translations with fallback, with primary taking precedence Args: primary: Primary translation dictionary (may be empty) fallback: Fallback translation dictionary Returns: Merged dictionary with primary values overriding fallback """ if not primary: return fallback.copy() result = fallback.copy() def merge_dict(target: dict, source: dict): """Recursively merge source into target""" for key, value in source.items(): if key in target and isinstance(target[key], dict) and isinstance(value, dict): merge_dict(target[key], value) else: target[key] = value merge_dict(result, primary) return result def _load_file(self, lang: str) -> Dict[str, Any]: """ Load a single translation file Args: lang: Language code Returns: Dictionary of translations, empty dict if file not found """ file_path = Path(self.translation_path) / f"{lang}.json" if not file_path.exists(): return {} try: with open(file_path, 'r', encoding='utf-8') as f: return json.load(f) except json.JSONDecodeError as e: print(f"Error parsing translation file {file_path}: {e}") return {} except Exception as e: print(f"Error loading translation file {file_path}: {e}") return {} def translate(self, key: str, **kwargs) -> str: """ Translate a key with optional formatting Args: key: Dot-separated key path (e.g., 'commands.wx.usage') **kwargs: Formatting parameters for string.format() Returns: Translated string, or key if translation not found """ # Navigate through nested dict structure keys = key.split('.') value = self.translations # Try requested language first for k in keys: if isinstance(value, dict) and k in value: value = value[k] else: # Fallback to English value = self.fallback_translations for k in keys: if isinstance(value, dict) and k in value: value = value[k] else: # Final fallback: return key (makes missing translations visible) return key # If we got a string, format it if kwargs provided if isinstance(value, str): if kwargs: try: return value.format(**kwargs) except (KeyError, ValueError) as e: # If formatting fails, return unformatted string return value return value # If value is not a string, return the key return key def reload(self): """Reload translation files (useful for development)""" self._load_translations() def get_available_languages(self) -> list: """ Get list of available language files Returns: List of language codes (e.g., ['en', 'es', 'fr']) """ languages = [] trans_path = Path(self.translation_path) if trans_path.exists(): for file in trans_path.glob('*.json'): languages.append(file.stem) return sorted(languages) def get_value(self, key: str) -> Any: """ Get a raw value from translations (can be string, list, dict, etc.) Args: key: Dot-separated key path (e.g., 'commands.hacker.sudo_errors') Returns: The value at the key path, or None if not found """ keys = key.split('.') value = self.translations # Try requested language first for k in keys: if isinstance(value, dict) and k in value: value = value[k] else: # Fallback to English value = self.fallback_translations for k in keys: if isinstance(value, dict) and k in value: value = value[k] else: # Not found return None break return value