875 lines
34 KiB
Python
875 lines
34 KiB
Python
# 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.<ClassName>", aiContext or docstring)
|
|
2. For each Field with json_schema_extra["label"]:
|
|
Registers t(label, "table.<ClassName>.<fieldName>", 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
|
|
|
|
# Render-hint label tokens (frontend_format_labels) are user-visible
|
|
# strings that appear in tables/forms (e.g. boolean labels
|
|
# ["Ja","-","Nein"], unit suffixes ["KB","MB","GB",...]). Register
|
|
# each non-empty token under a per-field context so they appear in
|
|
# the xx base set and get AI-translated like every other UI string.
|
|
formatLabels = extra.get("frontend_format_labels")
|
|
if isinstance(formatLabels, list):
|
|
fmtCtx = f"table.{className}.{fieldName}.format"
|
|
for token in formatLabels:
|
|
if isinstance(token, str) and token.strip():
|
|
t(token, fmtCtx, "")
|
|
|
|
# Pydantic v2 computed fields (@computed_field) — same handling as
|
|
# regular model_fields so labels and frontend_format_labels are
|
|
# registered for i18n and appear in MODEL_LABELS.
|
|
computedFields = getattr(cls, "model_computed_fields", {}) or {}
|
|
for fieldName, computedInfo in computedFields.items():
|
|
extra = getattr(computedInfo, "json_schema_extra", None)
|
|
if callable(extra) or not isinstance(extra, dict):
|
|
attributes.setdefault(fieldName, fieldName)
|
|
continue
|
|
label = extra.get("label")
|
|
if label:
|
|
desc = getattr(computedInfo, "description", "") or ""
|
|
t(label, f"table.{className}.{fieldName}", desc)
|
|
attributes[fieldName] = label
|
|
else:
|
|
attributes.setdefault(fieldName, fieldName)
|
|
|
|
formatLabels = extra.get("frontend_format_labels")
|
|
if isinstance(formatLabels, list):
|
|
fmtCtx = f"table.{className}.{fieldName}.format"
|
|
for token in formatLabels:
|
|
if isinstance(token, str) and token.strip():
|
|
t(token, fmtCtx, "")
|
|
|
|
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.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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 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),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 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
|
|
# 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 ""
|
|
),
|
|
)
|