# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Gateway i18n registry: t(), @i18nModel, boot-sync, in-memory cache. All UI-visible texts in the gateway (HTTPException details, model labels, API messages) are tagged with t() and registered at import time. At boot, the registry is synced to the xx base set in the DB. At runtime, t() returns the cached translation for the current request language. """ from __future__ import annotations import logging import re from contextvars import ContextVar from dataclasses import dataclass, field as dataclass_field from typing import Any, Dict, List, Optional, Tuple, Type from pydantic import BaseModel logger = logging.getLogger(__name__) # Matches {placeholderName} tokens used by t(...) param substitution in the # frontend (LanguageContext._applyParams) and the gateway. Allows ASCII # identifiers and digits, no spaces. _PLACEHOLDER_PATTERN = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}") def _enforceSourcePlaceholders(sourceKey: str, translatedValue: str) -> Tuple[str, bool]: """Repair a translated value so its placeholder tokens match the source key. Background: AI translators occasionally translate the *names* of placeholders even when instructed not to (e.g. ``{konten}`` -> ``{accounts}``). The frontend then cannot substitute params and the user sees raw ``{accounts}`` in the UI. Strategy (positional, conservative): - if the source has no placeholders -> nothing to do - if source and translation have the same set of tokens -> nothing to do - if both have the *same number* of tokens but different names -> swap each translation token with the source token at the same position - if counts differ -> leave the translation untouched (too risky to guess; surfaced as a logger.warning by the caller if desired) Returns ``(repairedValue, wasChanged)``. """ if not sourceKey or not translatedValue: return translatedValue, False sourceTokens = _PLACEHOLDER_PATTERN.findall(sourceKey) if not sourceTokens: return translatedValue, False valueTokens = _PLACEHOLDER_PATTERN.findall(translatedValue) if not valueTokens: return translatedValue, False if sourceTokens == valueTokens: return translatedValue, False if len(sourceTokens) != len(valueTokens): return translatedValue, False parts = _PLACEHOLDER_PATTERN.split(translatedValue) rebuilt = parts[0] for idx, srcTok in enumerate(sourceTokens): rebuilt += srcTok + parts[idx + 1] return rebuilt, True def _extractRegistrySourceText(obj: Any) -> str: """Resolve a str or multilingual dict to one canonical registry key string.""" if isinstance(obj, str): return obj if isinstance(obj, dict): return obj.get("xx") or next(iter(obj.values()), "") or "" return "" # --------------------------------------------------------------------------- # Registry (populated at import time by t() and @i18nModel) # --------------------------------------------------------------------------- @dataclass class _I18nRegistryEntry: context: str value: str _REGISTRY: Dict[str, _I18nRegistryEntry] = {} # --------------------------------------------------------------------------- # Translation cache (populated at boot by _loadCache) # --------------------------------------------------------------------------- _CACHE: Dict[str, Dict[str, str]] = {} # --------------------------------------------------------------------------- # Per-request language (set by middleware) # --------------------------------------------------------------------------- _CURRENT_LANGUAGE: ContextVar[str] = ContextVar("i18n_lang", default="de") # --------------------------------------------------------------------------- # Model labels (backwards-compatible with getModelLabels / getModelLabel) # --------------------------------------------------------------------------- MODEL_LABELS: Dict[str, Dict[str, Any]] = {} # --------------------------------------------------------------------------- # t() -- tag and translate # --------------------------------------------------------------------------- def t(key: str, context: str = "api", value: str = "") -> str: """Tag a UI-visible text for i18n and return the translation. At import time: registers the key with context and AI description. At runtime: returns the cached translation for _CURRENT_LANGUAGE. Falls back to [key] so missing translations are visible in the UI. """ if key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context=context, value=value) lang = _CURRENT_LANGUAGE.get() if lang == "de": return key return _CACHE.get(lang, {}).get(key, f"[{key}]") def resolveText(value: Any, lang: Optional[str] = None) -> str: """Resolve any value to a translated string for the current request language. Accepts str, dict, TextMultilingual, or None. - str: translate via t() (treats as i18n key / German plaintext key) - dict: multilingual user content — pick ``lang`` (or current context), then ``xx``, then first value - object with model_dump(): convert to dict first (TextMultilingual) - None/empty: return "" If ``lang`` is given, it temporarily overrides the context language for this call (used by schedulers that have an explicit user language). Missing i18n translations for string keys use t()'s ``[key]`` fallback. """ if lang is not None: token = _CURRENT_LANGUAGE.set(lang) try: return _resolveTextImpl(value) finally: _CURRENT_LANGUAGE.reset(token) return _resolveTextImpl(value) def _resolveTextImpl(value: Any) -> str: if value is None: return "" if isinstance(value, str): if not value.strip(): return "" return t(value) if hasattr(value, "model_dump"): value = value.model_dump() if isinstance(value, dict): if not value: return "" lang = _CURRENT_LANGUAGE.get() text = value.get(lang) or value.get("xx") if text: return str(text) first = next((v for v in value.values() if v), None) return str(first) if first else "" return str(value) def apiRouteContext(routeModuleName: str): """Return a callable that registers + translates HTTPException details. The key is registered eagerly in ``_REGISTRY`` the moment ``_apiMsg(key)`` is evaluated (module-level ``detail=routeApiMsg("…")`` runs at import time). At runtime ``t()`` returns the cached translation for the current language. """ _ctx = f"api.{routeModuleName}" def _apiMsg(key: str, value: str = "") -> str: if key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context=_ctx, value=value) return t(key, _ctx, value) return _apiMsg # --------------------------------------------------------------------------- # @i18nModel -- class decorator for Pydantic models # --------------------------------------------------------------------------- def i18nModel(modelLabel: str, aiContext: str = ""): """Class decorator: registers model and field labels for i18n. 1. Registers t(modelLabel, "table.", aiContext or docstring) 2. For each Field with json_schema_extra["label"]: Registers t(label, "table..", field.description) 3. Populates MODEL_LABELS for getModelLabels()/getModelLabel() in attributeUtils """ def _decorator(cls: Type[BaseModel]) -> Type[BaseModel]: className = cls.__name__ ctx = aiContext or _extractDocstringFirstLine(cls) t(modelLabel, f"table.{className}", ctx) attributes: Dict[str, str] = {} for fieldName, fieldInfo in cls.model_fields.items(): extra = fieldInfo.json_schema_extra if not isinstance(extra, dict): continue label = extra.get("label") if label: desc = fieldInfo.description or "" t(label, f"table.{className}.{fieldName}", desc) attributes[fieldName] = label else: attributes[fieldName] = fieldName MODEL_LABELS[className] = { "model": modelLabel, "attributes": attributes, } return cls return _decorator def _extractDocstringFirstLine(cls: type) -> str: doc = cls.__doc__ if not doc: return "" return doc.strip().split("\n")[0].strip() # --------------------------------------------------------------------------- # Language setter (called by middleware) # --------------------------------------------------------------------------- def _setLanguage(lang: str): """Set the language for the current request context.""" _CURRENT_LANGUAGE.set(lang) def _getLanguage() -> str: """Get the language for the current request context.""" return _CURRENT_LANGUAGE.get() def normalizePrimaryLanguageTag(tag: str, fallback: str = "de") -> str: """Primary language subtag from ``Accept-Language`` or a single BCP47 tag. Supports 2-letter (ISO 639-1) and 3-letter (ISO 639-2/3) primaries such as ``gsw``. Strips region/variant: ``de-CH`` → ``de``, ``zh-Hans-CN`` → ``zh``. """ if not tag or not isinstance(tag, str): return fallback first = tag.split(",")[0].split(";")[0].strip() if not first: return fallback primary = first.split("-")[0].split("_")[0].lower() if primary.isalpha() and 2 <= len(primary) <= 8: return primary return fallback # --------------------------------------------------------------------------- # Boot: scan route files for routeApiMsg("…") calls → register eagerly # --------------------------------------------------------------------------- _ROUTE_API_MSG_RE = None # compiled lazily def _scanRouteApiMsgKeys(): """Scan all gateway route/feature Python files for routeApiMsg("…") calls and register the keys in _REGISTRY so they appear in the boot DB sync. """ import re from pathlib import Path global _ROUTE_API_MSG_RE if _ROUTE_API_MSG_RE is None: _ROUTE_API_MSG_RE = re.compile( r"""routeApiMsg\(\s*(['"])((?:\\.|(?!\1).)+)\1""", ) gatewayRoot = Path(__file__).resolve().parents[1] scanDirs = [gatewayRoot / "routes", gatewayRoot / "features"] _ctxRe = re.compile(r'''apiRouteContext\(\s*['"]([^'"]+)['"]\s*\)''') for scanDir in scanDirs: if not scanDir.is_dir(): continue for pyFile in scanDir.rglob("*.py"): try: src = pyFile.read_text(encoding="utf-8", errors="replace") except OSError: continue ctxMatch = _ctxRe.search(src) if not ctxMatch: continue ctx = f"api.{ctxMatch.group(1)}" for m in _ROUTE_API_MSG_RE.finditer(src): key = m.group(2).replace("\\'", "'").replace('\\"', '"') if key and key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") logger.info("i18n route scan: %d api.* keys in registry after scan", sum(1 for e in _REGISTRY.values() if e.context.startswith("api."))) def _registerNavLabels(): """Register all navigation labels from NAVIGATION_SECTIONS as i18n keys. Called at boot before DB sync so that nav labels appear in the xx base set and can be translated via the Admin UI. """ try: from modules.system.mainSystem import NAVIGATION_SECTIONS except ImportError: logger.warning("i18n: could not import NAVIGATION_SECTIONS for nav label registration") return count = 0 for section in NAVIGATION_SECTIONS: title = section.get("title", "") if title and title not in _REGISTRY: _REGISTRY[title] = _I18nRegistryEntry(context="nav", value="") count += 1 for item in section.get("items", []): label = item.get("label", "") if label and label not in _REGISTRY: _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") count += 1 for subgroup in section.get("subgroups", []): sgTitle = subgroup.get("title", "") if sgTitle and sgTitle not in _REGISTRY: _REGISTRY[sgTitle] = _I18nRegistryEntry(context="nav", value="") count += 1 for item in subgroup.get("items", []): label = item.get("label", "") if label and label not in _REGISTRY: _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") count += 1 logger.info("i18n nav labels: registered %d nav keys", count) def _registerFeatureUiLabels(): """Register FEATURE_LABEL and UI_OBJECTS labels from all feature modules (German i18n keys).""" try: from modules.system import mainSystem as _mainSystem _fl = getattr(_mainSystem, "FEATURE_LABEL", None) if isinstance(_fl, str) and _fl and _fl not in _REGISTRY: _REGISTRY[_fl] = _I18nRegistryEntry(context="nav", value="") except ImportError: pass _featureModulePaths = ( "modules.features.trustee.mainTrustee", "modules.features.graphicalEditor.mainGraphicalEditor", "modules.features.commcoach.mainCommcoach", "modules.features.teamsbot.mainTeamsbot", "modules.features.workspace.mainWorkspace", "modules.features.realEstate.mainRealEstate", "modules.features.neutralization.mainNeutralization", "modules.features.chatbot.mainChatbot", ) added = 0 for modPath in _featureModulePaths: try: mod = __import__(modPath, fromlist=["FEATURE_LABEL", "UI_OBJECTS"]) except ImportError: continue fl = getattr(mod, "FEATURE_LABEL", None) if isinstance(fl, str) and fl and fl not in _REGISTRY: _REGISTRY[fl] = _I18nRegistryEntry(context="nav", value="") added += 1 for uiObj in getattr(mod, "UI_OBJECTS", []) or []: base = _extractRegistrySourceText(uiObj.get("label")) if base and base not in _REGISTRY: _REGISTRY[base] = _I18nRegistryEntry(context="nav", value="") added += 1 logger.info("i18n feature UI labels: %d new keys (nav context)", added) def _registerRbacLabels(): """Register DATA_OBJECTS, RESOURCE_OBJECTS labels and TEMPLATE_ROLES descriptions from all feature modules and system module as i18n keys. context mapping: - DATA_OBJECTS → rbac.data - RESOURCE_OBJECTS → rbac.resource - TEMPLATE_ROLES[].description (xx source) → rbac.role - QUICK_ACTIONS[].label/description (xx source) → rbac.quickaction - QUICK_ACTION_CATEGORIES[].label (xx source) → rbac.quickaction """ _systemModule = "modules.system.mainSystem" _featureModulePaths = ( _systemModule, "modules.features.trustee.mainTrustee", "modules.features.graphicalEditor.mainGraphicalEditor", "modules.features.commcoach.mainCommcoach", "modules.features.teamsbot.mainTeamsbot", "modules.features.workspace.mainWorkspace", "modules.features.realEstate.mainRealEstate", "modules.features.neutralization.mainNeutralization", "modules.features.chatbot.mainChatbot", ) added = 0 for modPath in _featureModulePaths: try: mod = __import__(modPath, fromlist=[ "DATA_OBJECTS", "RESOURCE_OBJECTS", "TEMPLATE_ROLES", "QUICK_ACTIONS", "QUICK_ACTION_CATEGORIES", ]) except ImportError: continue for dataObj in getattr(mod, "DATA_OBJECTS", []) or []: key = _extractRegistrySourceText(dataObj.get("label")) if key and key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context="rbac.data", value="") added += 1 for resObj in getattr(mod, "RESOURCE_OBJECTS", []) or []: key = _extractRegistrySourceText(resObj.get("label")) if key and key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context="rbac.resource", value="") added += 1 for role in getattr(mod, "TEMPLATE_ROLES", []) or []: key = _extractRegistrySourceText(role.get("description")) if key and key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context="rbac.role", value="") added += 1 for qa in getattr(mod, "QUICK_ACTIONS", []) or []: for field in ("label", "description"): key = _extractRegistrySourceText(qa.get(field)) if key and key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") added += 1 for cat in getattr(mod, "QUICK_ACTION_CATEGORIES", []) or []: key = _extractRegistrySourceText(cat.get("label")) if key and key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") added += 1 logger.info("i18n rbac labels: %d new keys (rbac.* context)", added) def _registerServiceCenterLabels(): """Register service-center category labels and bootstrap role descriptions.""" added = 0 try: from modules.serviceCenter.registry import IMPORTABLE_SERVICES for svc in IMPORTABLE_SERVICES.values(): key = _extractRegistrySourceText(svc.get("label")) if key and key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context="service", value="") added += 1 except ImportError: pass _bootstrapRoleDescriptions = [ "Administrator - Benutzer und Ressourcen im Mandanten verwalten", "Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", "Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze", "System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten", ] for desc in _bootstrapRoleDescriptions: if desc not in _REGISTRY: _REGISTRY[desc] = _I18nRegistryEntry(context="rbac.role", value="") added += 1 logger.info("i18n service/bootstrap labels: %d new keys", added) def _registerNodeLabels(): """Register all graph-editor node labels, descriptions, parameter descriptions, output labels, port descriptions, category labels, and entry-point titles.""" added = 0 def _reg(key: str, ctx: str): nonlocal added if key and key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") added += 1 try: from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES for nd in STATIC_NODE_TYPES: _reg(_extractRegistrySourceText(nd.get("label")), "node.label") _reg(_extractRegistrySourceText(nd.get("description")), "node.desc") for param in nd.get("parameters", []) or []: _reg(_extractRegistrySourceText(param.get("description")), "node.param") _reg(_extractRegistrySourceText(param.get("label")), "node.param") outLabels = nd.get("outputLabels") if isinstance(outLabels, dict): sourceList = outLabels.get("xx") or next(iter(outLabels.values()), []) if not isinstance(sourceList, list): sourceList = [] for lbl in sourceList: _reg(lbl, "node.output") elif isinstance(outLabels, list): for lbl in outLabels: _reg(lbl, "node.output") except ImportError: pass try: from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG for schema in PORT_TYPE_CATALOG.values(): for field in getattr(schema, "fields", []) or []: desc = getattr(field, "description", None) if desc: _reg(_extractRegistrySourceText(desc if isinstance(desc, (str, dict)) else None), "port.desc") except ImportError: pass _nodeCategoryLabels = [ "Trigger", "Eingabe/Mensch", "Ablauf", "Daten", "KI", "Datei", "E-Mail", "SharePoint", "ClickUp", "Treuhand", ] for lbl in _nodeCategoryLabels: _reg(lbl, "node.category") _entryPointTitles = ["Jetzt ausführen", "Start"] for lbl in _entryPointTitles: _reg(lbl, "node.entry") logger.info("i18n node labels: %d new keys (node.*/port.* context)", added) def _registerAccountingConnectorLabels(): """Register all accounting connector configField labels (label) at boot time. Connector ``getRequiredConfigFields()`` is normally invoked lazily at first request, which is too late for the boot-sync. We discover the connectors here so their ``t()`` calls register the keys before they are written to the ``xx`` set and AI-translated for every active language set. """ added = 0 try: from modules.features.trustee.accounting.accountingRegistry import _getAccountingRegistry except ImportError: logger.debug("i18n accounting connectors: registry not importable") return try: registry = _getAccountingRegistry() except Exception as e: logger.warning("i18n accounting connectors: registry init failed: %s", e) return for connectorType, connector in (registry._connectors or {}).items(): try: for field in connector.getRequiredConfigFields(): key = getattr(field, "label", "") or "" if not isinstance(key, str) or not key: continue if key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry( context=f"connector.accounting.{connectorType}", value="", ) added += 1 except Exception as e: logger.warning( "i18n accounting connector %s: failed to read fields: %s", connectorType, e, ) logger.info("i18n accounting connector labels: %d new keys", added) def _registerDatamodelOptionLabels(): """Register all frontend_options labels from Pydantic datamodels and subscription plans.""" added = 0 def _reg(key: str, ctx: str): nonlocal added if key and key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") added += 1 _datamodelModules = ( "modules.datamodels.datamodelRbac", "modules.datamodels.datamodelChat", "modules.datamodels.datamodelMessaging", "modules.datamodels.datamodelNotification", "modules.datamodels.datamodelUam", "modules.datamodels.datamodelFiles", "modules.datamodels.datamodelDataSource", "modules.datamodels.datamodelFeatureDataSource", "modules.datamodels.datamodelUiLanguage", "modules.features.trustee.datamodelFeatureTrustee", "modules.features.neutralization.datamodelFeatureNeutralizer", ) for modPath in _datamodelModules: try: mod = __import__(modPath, fromlist=["__all__"]) except ImportError: continue for attrName in dir(mod): cls = getattr(mod, attrName, None) if not isinstance(cls, type) or not issubclass(cls, BaseModel): continue for fieldName, fieldInfo in cls.model_fields.items(): extra = (fieldInfo.json_schema_extra or {}) if hasattr(fieldInfo, "json_schema_extra") else {} if not isinstance(extra, dict): continue options = extra.get("frontend_options") if not isinstance(options, list): continue ctx = f"option.{cls.__name__}.{fieldName}" for opt in options: if isinstance(opt, dict): _reg(_extractRegistrySourceText(opt.get("label")), ctx) try: from modules.datamodels.datamodelSubscription import BUILTIN_PLANS for plan in BUILTIN_PLANS.values(): _reg(_extractRegistrySourceText(getattr(plan, "title", None)), "subscription.title") _reg(_extractRegistrySourceText(getattr(plan, "description", None)), "subscription.desc") except (ImportError, AttributeError): pass logger.info("i18n datamodel option labels: %d new keys", added) # --------------------------------------------------------------------------- # Boot: sync registry to DB # --------------------------------------------------------------------------- async def _syncRegistryToDb(): """Boot hook: write all registered keys into UiLanguageSet(xx). 1. Scans route files for routeApiMsg("…") to eagerly register api.* keys. 2. Registers navigation labels as nav.* keys. 3. Registers feature UI labels (FEATURE_LABEL, UI_OBJECTS). 4. Registers RBAC labels (DATA/RESOURCE/ROLE/QuickAction). 5. Merges with existing UI keys (context="ui"), only touches gateway keys. """ _scanRouteApiMsgKeys() _registerNavLabels() _registerFeatureUiLabels() _registerRbacLabels() _registerServiceCenterLabels() _registerNodeLabels() _registerDatamodelOptionLabels() _registerAccountingConnectorLabels() if not _REGISTRY: logger.info("i18n registry: no keys to sync (empty registry)") return from modules.datamodels.datamodelUiLanguage import UiLanguageSet from modules.shared.configuration import APP_CONFIG from modules.connectors.connectorDbPostgre import _get_cached_connector from modules.shared.timeUtils import getUtcTimestamp db = _get_cached_connector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbDatabase="poweron_management", dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), userId="__i18n_boot__", ) rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"}) gatewayEntries = [ {"context": entry.context, "key": key, "value": entry.value} for key, entry in _REGISTRY.items() ] gatewayKeys = set(_REGISTRY.keys()) if not rows: now = getUtcTimestamp() rec = { "id": "xx", "label": "Basisset (Meta)", "entries": gatewayEntries, "status": "complete", "isDefault": True, "sysCreatedAt": now, "sysCreatedBy": "__i18n_boot__", "sysModifiedAt": now, "sysModifiedBy": "__i18n_boot__", } db.recordCreate(UiLanguageSet, rec) logger.info("i18n boot-sync: created xx set with %d gateway keys", len(gatewayEntries)) return row = dict(rows[0]) existingEntries: List[dict] = row.get("entries") or [] if not isinstance(existingEntries, list): existingEntries = [] uiEntries = [e for e in existingEntries if e.get("context", "") == "ui"] oldGatewayEntries = [ e for e in existingEntries if e.get("context", "") != "ui" ] oldGatewayByKey = {e["key"]: e for e in oldGatewayEntries} added = 0 updated = 0 removed = 0 newGatewayEntries: List[dict] = [] for key, entry in _REGISTRY.items(): newEntry = {"context": entry.context, "key": key, "value": entry.value} old = oldGatewayByKey.get(key) if old is None: added += 1 elif old.get("context") != entry.context or old.get("value") != entry.value: updated += 1 newGatewayEntries.append(newEntry) removed = len(set(oldGatewayByKey.keys()) - gatewayKeys) mergedEntries = uiEntries + newGatewayEntries if added == 0 and updated == 0 and removed == 0: logger.info("i18n boot-sync: xx set up-to-date (%d gateway + %d ui keys)", len(newGatewayEntries), len(uiEntries)) return now = getUtcTimestamp() row["entries"] = mergedEntries if "keys" in row: del row["keys"] row["sysModifiedAt"] = now row["sysModifiedBy"] = "__i18n_boot__" db.recordModify(UiLanguageSet, "xx", row) logger.info( "i18n boot-sync: xx updated (+%d added, ~%d updated, -%d removed, total=%d gateway + %d ui)", added, updated, removed, len(newGatewayEntries), len(uiEntries), ) # --------------------------------------------------------------------------- # Boot: load translation cache # --------------------------------------------------------------------------- async def _loadCache(): """Boot hook: load all UiLanguageSets into the in-memory cache. Also persistently repairs placeholder mismatches in the DB: if an entry's value has placeholder *names* that differ from the source key (typical AI translation mishap, e.g. ``{konten}`` -> ``{accounts}``), the source names are restored positionally and the row is written back to the DB. Idempotent and safe -- only mutates when the placeholder count matches and the names actually differ. After this, t() lookups are O(1) dict access with no DB calls. """ from modules.datamodels.datamodelUiLanguage import UiLanguageSet from modules.shared.configuration import APP_CONFIG from modules.connectors.connectorDbPostgre import _get_cached_connector db = _get_cached_connector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbDatabase="poweron_management", dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), userId="__i18n_cache__", ) rows = db.getRecordset(UiLanguageSet) _CACHE.clear() repairedTotal = 0 persistedLanguages = 0 for row in rows: code = row.get("id", "") if code == "xx": continue entries = row.get("entries") if not isinstance(entries, list): continue langDict: Dict[str, str] = {} repairedInLang = 0 # Walk a mutable copy so we can write the corrected entries back to # the row without re-reading from the DB. for entry in entries: key = entry.get("key", "") val = entry.get("value", "") if not key or not val: continue fixed, changed = _enforceSourcePlaceholders(key, val) if changed: entry["value"] = fixed repairedInLang += 1 langDict[key] = fixed if langDict: _CACHE[code] = langDict if repairedInLang: repairedTotal += repairedInLang try: rowToSave = dict(row) rowToSave["entries"] = entries if "keys" in rowToSave: del rowToSave["keys"] db.recordModify(UiLanguageSet, code, rowToSave) persistedLanguages += 1 logger.info( "i18n boot repair: fixed and persisted %d placeholder mismatches in language '%s'", repairedInLang, code, ) except Exception as ex: # Persistence is best-effort -- the in-memory cache is # already correct (langDict above contains the fixed # values), so the UI works either way. Log and move on. logger.warning( "i18n boot repair: in-memory fixed %d entries in '%s' but DB persist failed: %s", repairedInLang, code, ex, ) logger.info( "i18n cache loaded: %d languages, %d total keys%s", len(_CACHE), sum(len(v) for v in _CACHE.values()), ( f" (boot-repaired {repairedTotal} placeholders, " f"persisted to {persistedLanguages} language sets)" if repairedTotal else "" ), )