Files
meshcore-bot/modules/i18n.py
T
agessaman 698f18dc6e fix(i18n): anchor the local catalog on local_dir_path and wire it everywhere
The local translation path defaulted to a bare `local/translations/`, resolved
against the process cwd and unrelated to `[Bot] local_dir_path`. Since
`local_dir_path` already selects where an operator's commands, service plugins
and config overlay live, the catalog belongs in that same tree. It now defaults
to `<local_dir_path>/translations`, resolved absolute against the bot root, so
relocating `local_dir_path` moves the catalog with it and the lookup no longer
depends on the working directory. An explicit `local_translation_path` still
wins.

Only the constructor at startup was passing the local path. `get_translator()`
builds and caches a translator per detected language, and `reload_config()`
builds a fresh one, and both were still constructing `Translator` with the
distributed path alone. So local overrides silently vanished from any reply that
used the sender's language, and did not survive a config reload. Both now pass
it, `reload_config()` republishes it alongside `translation_path`, and it is
snapshotted and restored on rollback. The DummyTranslator fallback sets it too,
since `get_translator()` would otherwise raise AttributeError there.

Collapsed `_deep_merge_translations` into the existing `_merge_translations`,
which already merged deeply with the primary winning. Rewrote that one to stop
mutating its input: it shallow-copied `fallback` and then recursed into the
shared sub-dicts, so merging a base language over English corrupted the cached
English catalog at every level below the first. Also replaced the two `print()`
calls on the error paths with logger calls.

Registered `local_translation_path` in the config schema next to
`translation_path`, and reverted a trailing-whitespace reflow that touched 84
lines of config.ini.example for a one-key addition.

Tests: local overlay (single-string override, added keys, local-only catalog,
missing directory), the merge no longer mutating its fallback, the default
following `local_dir_path`, an explicit setting overriding it, the overlay
reaching `bot.translator` end to end, and a reload picking up a moved path.
2026-09-07 20:28:45 -07:00

254 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""
Internationalization (i18n) module for MeshCore Bot
Provides translation functionality for bot commands and responses
"""
import json
import logging
from importlib import resources
from pathlib import Path
from typing import Any
logger = logging.getLogger("MeshCoreBot")
class Translator:
"""Handles translation loading and lookup for the bot"""
def __init__(self, language: str = 'en', translation_path: str = 'translations/', local_translation_path: str = 'local/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 the distributed translation files directory
local_translation_path: Path to the operator's own translation files, merged
over the distributed ones key by key. Defaults to
``<local_dir_path>/translations`` when set from config.
"""
self.language = language
self.translation_path = translation_path
self.local_translation_path = local_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()
for key, value in primary.items():
existing = result.get(key)
if isinstance(existing, dict) and isinstance(value, dict):
# Rebuild the sub-dict rather than mutating it in place: it is still
# shared with `fallback`, which the caller keeps using.
result[key] = self._merge_translations(value, existing)
else:
result[key] = value
return result
def _load_file(self, lang: str) -> dict[str, Any]:
"""
Load a language's catalog: the distributed file, overlaid with the local one
The local catalog is merged key by key over the distributed one, so an operator
can translate their own local commands, or override individual strings, without
editing a shipped file.
Args:
lang: Language code
Returns:
Dictionary of translations, empty dict if no catalog was found
"""
file_path = Path(self.translation_path) / f"{lang}.json"
local_file_path = Path(self.local_translation_path) / f"{lang}.json"
try:
# An explicitly configured filesystem catalog always wins. The
# package fallback makes the defaults work from an installed wheel
# (where ``translations/`` is not relative to the current cwd).
catalog: dict[str, Any] = {}
found = False
for path in (file_path, local_file_path):
if not path.is_file():
continue
logger.info("Loading translation catalog: %s", path)
with open(path, encoding='utf-8') as f:
# Later file wins on overlapping keys.
catalog = self._merge_translations(json.load(f), catalog)
found = True
if found:
# A configured catalog wins even when it is empty, so an operator can
# deliberately blank one out without the bundled defaults reappearing.
return catalog
if not self._uses_bundled_defaults():
return {}
bundled = resources.files("translations").joinpath(f"{lang}.json")
if not bundled.is_file():
return {}
return json.loads(bundled.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
logger.error("Error parsing translation file for %r: %s", lang, e)
return {}
except Exception as e:
logger.error("Error loading translation file for %r: %s", lang, 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):
# 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)
if self._uses_bundled_defaults():
try:
for resource in resources.files("translations").iterdir():
if resource.is_file() and resource.name.endswith(".json"):
languages.append(resource.name[:-5])
except (ModuleNotFoundError, OSError):
pass
return sorted(set(languages))
def _uses_bundled_defaults(self) -> bool:
"""Return whether ``translation_path`` names the shipped catalog."""
normalized = str(self.translation_path).replace("\\", "/").rstrip("/")
return normalized == "translations"
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