# app/utils/i18n.py """ Internationalization (i18n) utilities for multi-language support. This module provides: - Language constants and configuration - JSON-based translation loading - Translation helper functions - Jinja2 template integration Supported languages: - en: English - fr: French (default for Luxembourg) - de: German - lb: Luxembourgish """ import json import logging from functools import lru_cache from pathlib import Path from typing import Any logger = logging.getLogger(__name__) # ============================================================================ # Language Configuration # ============================================================================ # Supported language codes SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"] # Default language (Luxembourg context) DEFAULT_LANGUAGE = "fr" # Language display names (in their native language) LANGUAGE_NAMES = { "en": "English", "fr": "Français", "de": "Deutsch", "lb": "Lëtzebuergesch", } # Language display names in English (for admin) LANGUAGE_NAMES_EN = { "en": "English", "fr": "French", "de": "German", "lb": "Luxembourgish", } # Flag emoji for language selector (optional) LANGUAGE_FLAGS = { "en": "🇬🇧", "fr": "🇫🇷", "de": "🇩🇪", "lb": "🇱🇺", } # ============================================================================ # Translation Storage # ============================================================================ # Path to core locale files LOCALES_PATH = Path(__file__).parent.parent.parent / "static" / "locales" # Path to modules directory (for module-specific locales) MODULES_PATH = Path(__file__).parent.parent / "modules" # In-memory cache for loaded translations _translations: dict[str, dict] = {} # Cache for module locale directories _module_locale_dirs: list[tuple[str, Path]] | None = None def get_locales_path() -> Path: """Get the path to core locale files.""" return LOCALES_PATH def get_module_locale_dirs() -> list[tuple[str, Path]]: """ Discover module locale directories. Returns: List of (module_code, locales_path) tuples for modules with locales """ global _module_locale_dirs if _module_locale_dirs is not None: return _module_locale_dirs _module_locale_dirs = [] if not MODULES_PATH.exists(): return _module_locale_dirs for module_dir in sorted(MODULES_PATH.iterdir()): if module_dir.is_dir(): locales_dir = module_dir / "locales" if locales_dir.exists() and locales_dir.is_dir(): module_code = module_dir.name _module_locale_dirs.append((module_code, locales_dir)) logger.debug(f"[i18n] Found module locales: {module_code} at {locales_dir}") return _module_locale_dirs def _load_json_file(file_path: Path) -> dict: """Load a JSON file and return its contents.""" try: with open(file_path, encoding="utf-8") as f: return json.load(f) except json.JSONDecodeError as e: logger.error(f"Invalid JSON in translation file {file_path}: {e}") return {} except Exception as e: logger.error(f"Error loading translation file {file_path}: {e}") return {} def _deep_merge(base: dict, override: dict) -> dict: """ Deep merge two dictionaries. Override values take precedence. Args: base: Base dictionary override: Dictionary with values to merge/override Returns: Merged dictionary """ result = base.copy() for key, value in override.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = _deep_merge(result[key], value) else: result[key] = value return result @lru_cache(maxsize=10) def load_translations(language: str) -> dict: """ Load translations for a specific language from JSON files. Loads core translations first, then merges module-specific translations. Module translations are namespaced under their module code. Args: language: Language code (en, fr, de, lb) Returns: Dictionary of translations, empty dict if file not found Example: Core: {"common": {"save": "Save"}} CMS module: {"pages": {"title": "Page Title"}} Result: {"common": {"save": "Save"}, "cms": {"pages": {"title": "Page Title"}}} """ if language not in SUPPORTED_LANGUAGES: logger.warning(f"Unsupported language requested: {language}") language = DEFAULT_LANGUAGE # Load core translations core_file = LOCALES_PATH / f"{language}.json" if core_file.exists(): translations = _load_json_file(core_file) else: logger.warning(f"Core translation file not found: {core_file}") if language != DEFAULT_LANGUAGE: return load_translations(DEFAULT_LANGUAGE) translations = {} # Load and merge module translations for module_code, locales_dir in get_module_locale_dirs(): module_file = locales_dir / f"{language}.json" if module_file.exists(): module_translations = _load_json_file(module_file) if module_translations: # Namespace module translations under module code if module_code in translations: # Merge with existing (e.g., if core has some cms.* keys) translations[module_code] = _deep_merge( translations[module_code], module_translations ) else: translations[module_code] = module_translations logger.debug(f"[i18n] Loaded {language} translations for module: {module_code}") return translations def clear_translation_cache(): """Clear the translation cache (useful for development/testing).""" global _module_locale_dirs load_translations.cache_clear() _translations.clear() _module_locale_dirs = None # ============================================================================ # Translation Functions # ============================================================================ def get_nested_value(data: dict, key_path: str, default: str = None) -> str: """ Get a nested value from a dictionary using dot notation. Args: data: Dictionary to search key_path: Dot-separated path (e.g., "common.save") default: Default value if key not found Returns: Value at the key path, or default if not found """ keys = key_path.split(".") value = data for key in keys: if isinstance(value, dict) and key in value: value = value[key] else: return default if default is not None else key_path return ( value if isinstance(value, str) else (default if default is not None else key_path) ) def translate( key: str, language: str = None, default: str = None, **kwargs: Any, ) -> str: """ Translate a key to the specified language. Args: key: Translation key (dot notation, e.g., "common.save") language: Target language code (defaults to DEFAULT_LANGUAGE) default: Default value if translation not found **kwargs: Variables for string interpolation Returns: Translated string with variables interpolated Example: translate("common.welcome", language="fr", name="John") # Returns: "Bienvenue, John!" (if translation is "Bienvenue, {name}!") """ if language is None: language = DEFAULT_LANGUAGE translations = load_translations(language) text = get_nested_value(translations, key, default) # If translation not found and we're not already in default language, try default if text == key and language != DEFAULT_LANGUAGE: translations = load_translations(DEFAULT_LANGUAGE) text = get_nested_value(translations, key, default) # Interpolate variables if kwargs and isinstance(text, str): try: text = text.format(**kwargs) except KeyError as e: logger.warning( f"Missing interpolation variable in translation '{key}': {e}" ) return text def t(key: str, language: str = None, **kwargs: Any) -> str: """ Shorthand alias for translate(). Example: t("common.save", "fr") # Returns French translation """ return translate(key, language, **kwargs) # ============================================================================ # Jinja2 Integration # ============================================================================ class TranslationContext: """ Translation context for use in Jinja2 templates. Provides a callable interface for the _() function in templates. """ def __init__(self, language: str = None): """Initialize with a specific language.""" self.language = language or DEFAULT_LANGUAGE def __call__(self, key: str, **kwargs: Any) -> str: """Translate a key.""" return translate(key, self.language, **kwargs) def set_language(self, language: str): """Change the current language.""" if language in SUPPORTED_LANGUAGES: self.language = language else: logger.warning(f"Attempted to set unsupported language: {language}") def create_translation_context(language: str = None) -> TranslationContext: """Create a translation context for templates.""" return TranslationContext(language) def get_jinja2_globals(language: str = None) -> dict: """ Get globals to add to Jinja2 environment for i18n support. Returns: Dictionary of globals for Jinja2 templates """ ctx = create_translation_context(language) return { "_": ctx, # Main translation function: {{ _("common.save") }} "t": ctx, # Alias: {{ t("common.save") }} "SUPPORTED_LANGUAGES": SUPPORTED_LANGUAGES, "DEFAULT_LANGUAGE": DEFAULT_LANGUAGE, "LANGUAGE_NAMES": LANGUAGE_NAMES, "LANGUAGE_FLAGS": LANGUAGE_FLAGS, "current_language": language or DEFAULT_LANGUAGE, } # ============================================================================ # Language Resolution Helpers # ============================================================================ def resolve_store_dashboard_language( user_preferred: str | None, store_dashboard: str | None, cookie_language: str | None = None, ) -> str: """ Resolve language for store dashboard. Priority: 1. Cookie (explicit UI action via language switcher) 2. User's preferred_language (DB preference from profile settings) 3. Store's dashboard_language 4. System default (fr) """ if cookie_language and cookie_language in SUPPORTED_LANGUAGES: return cookie_language if user_preferred and user_preferred in SUPPORTED_LANGUAGES: return user_preferred if store_dashboard and store_dashboard in SUPPORTED_LANGUAGES: return store_dashboard return DEFAULT_LANGUAGE def resolve_storefront_language( customer_preferred: str | None, session_language: str | None, store_storefront: str | None, browser_language: str | None, enabled_languages: list[str] | None = None, ) -> str: """ Resolve language for storefront. Priority: 1. Customer's preferred_language (if logged in and set) 2. Session/cookie language 3. Store's storefront_language 4. Browser Accept-Language header 5. System default (fr) Args: customer_preferred: Customer's saved preference session_language: Language from session/cookie store_storefront: Store's default storefront language browser_language: Primary language from Accept-Language header enabled_languages: List of languages enabled for this storefront """ # Explicit user choices (customer preference, session/cookie) should be # respected if they are valid platform languages, regardless of # store's enabled_languages. The store setting only constrains automatic # fallback (browser language, store default). explicit_choices = [customer_preferred, session_language] for lang in explicit_choices: if lang and lang in SUPPORTED_LANGUAGES: return lang # For automatic fallback, filter by store's enabled languages fallback_candidates = [store_storefront, browser_language, DEFAULT_LANGUAGE] if enabled_languages: enabled_set = set(enabled_languages) for lang in fallback_candidates: if lang and lang in SUPPORTED_LANGUAGES and lang in enabled_set: return lang else: for lang in fallback_candidates: if lang and lang in SUPPORTED_LANGUAGES: return lang return DEFAULT_LANGUAGE def parse_accept_language(accept_language: str | None) -> str | None: """ Parse Accept-Language header and return the best supported language. Args: accept_language: Accept-Language header value Returns: Best matching supported language code, or None """ if not accept_language: return None # Parse header: "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7" languages = [] for part in accept_language.split(","): part = part.strip() if ";q=" in part: lang, q = part.split(";q=") try: quality = float(q) except ValueError: quality = 0.0 else: lang = part quality = 1.0 # Extract base language code (e.g., "fr-FR" -> "fr") lang_code = lang.split("-")[0].lower() languages.append((lang_code, quality)) # Sort by quality (highest first) languages.sort(key=lambda x: x[1], reverse=True) # Return first supported language for lang_code, _ in languages: if lang_code in SUPPORTED_LANGUAGES: return lang_code return None # ============================================================================ # Utility Functions # ============================================================================ def get_language_choices() -> list[tuple[str, str]]: """ Get language choices for form select fields. Returns: List of (code, name) tuples """ return [(code, LANGUAGE_NAMES[code]) for code in SUPPORTED_LANGUAGES] def get_language_info(language: str) -> dict: """ Get full information about a language. Args: language: Language code Returns: Dictionary with code, name, name_en, flag """ if language not in SUPPORTED_LANGUAGES: language = DEFAULT_LANGUAGE return { "code": language, "name": LANGUAGE_NAMES.get(language, language), "name_en": LANGUAGE_NAMES_EN.get(language, language), "flag": LANGUAGE_FLAGS.get(language, "🌐"), } def is_rtl_language(language: str) -> bool: """ Check if a language is right-to-left. Currently all supported languages are LTR. """ # RTL languages (not currently supported, but ready for future) rtl_languages = ["ar", "he", "fa"] return language in rtl_languages