Files
meshcore-bot/modules/i18n.py

215 lines
7.4 KiB
Python

#!/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