# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ i18n boot-time logic: label discovery, DB sync, and cache loading. Called once at app startup (from app.py). This module MAY import from system, features, serviceCenter, connectors — it runs after all modules are importable and is never imported at module-level by datamodels or shared. """ from __future__ import annotations import logging import re from pathlib import Path from typing import Any, Dict, List, Type from pydantic import BaseModel from modules.shared.i18nRegistry import ( _CACHE, _CURRENT_LANGUAGE, _enforceSourcePlaceholders, _extractRegistrySourceText, _I18nRegistryEntry, _REGISTRY, t, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # 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. """ 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.""" 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.""" 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", ) 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.""" _featureModulePaths = ( "modules.system.mainSystem", "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", ) 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(serviceLabels: list = None): """Register service-center category labels and bootstrap role descriptions. serviceLabels is injected by app.py (Composition Root) to avoid system(L4) → serviceCenter(L5) upward import. """ added = 0 for label in (serviceLabels or []): key = _extractRegistrySourceText(label) if key and key not in _REGISTRY: _REGISTRY[key] = _I18nRegistryEntry(context="service", value="") added += 1 _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(accountingLabels: list = None): """Register accounting connector configField labels at boot time. Args: accountingLabels: List of dicts with keys 'label' and 'connectorType', injected from app.py to avoid features-import. """ if not accountingLabels: return added = 0 for entry in accountingLabels: key = entry.get("label", "") connectorType = entry.get("connectorType", "unknown") 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 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.datamodelFeatures", "modules.datamodels.datamodelUiLanguage", "modules.datamodels.datamodelViews", "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) # --------------------------------------------------------------------------- # Public boot API (called by app.py) # --------------------------------------------------------------------------- async def syncRegistryToDb(serviceLabels: list = None, accountingLabels: list = None): """Boot hook: discover all i18n keys and write them into UiLanguageSet(xx). Args: serviceLabels: Service label strings injected from app.py (avoids upward import). accountingLabels: Accounting connector field labels injected from app.py. """ _scanRouteApiMsgKeys() _registerNavLabels() _registerFeatureUiLabels() _registerRbacLabels() _registerServiceCenterLabels(serviceLabels) _registerNodeLabels() _registerDatamodelOptionLabels() _registerAccountingConnectorLabels(accountingLabels) 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 getCachedConnector from modules.shared.timeUtils import getUtcTimestamp db = getCachedConnector( 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), ) async def loadCache(): """Boot hook: load all UiLanguageSets into the in-memory translation cache. Also persistently repairs placeholder mismatches in the DB. 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 getCachedConnector db = getCachedConnector( 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 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: 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 "" ), )