diff --git a/app.py b/app.py index fa92e882..937cab28 100644 --- a/app.py +++ b/app.py @@ -491,12 +491,12 @@ from modules.auth import ( ) # i18n language detection middleware (sets per-request language from Accept-Language header) -from modules.shared.i18nRegistry import _setLanguage +from modules.shared.i18nRegistry import _setLanguage, normalizePrimaryLanguageTag @app.middleware("http") async def _i18nMiddleware(request: Request, call_next): acceptLang = request.headers.get("Accept-Language", "") - lang = acceptLang[:2].lower() if len(acceptLang) >= 2 and acceptLang[:2].isalpha() else "de" + lang = normalizePrimaryLanguageTag(acceptLang, "de") _setLanguage(lang) return await call_next(request) diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py index 9597eb2f..7bac8672 100644 --- a/modules/datamodels/datamodelUam.py +++ b/modules/datamodels/datamodelUam.py @@ -14,7 +14,7 @@ from typing import Optional, List, Dict, Any from enum import Enum from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.i18nRegistry import i18nModel +from modules.shared.i18nRegistry import i18nModel, normalizePrimaryLanguageTag from modules.shared.timeUtils import getUtcTimestamp @@ -243,17 +243,12 @@ class User(PowerOnModel): ) language: str = Field( default="de", - description="Preferred language of the user (ISO 639-1 code: de, en, fr, it)", + description="Preferred UI language code (must exist as UiLanguageSet; loaded from /api/i18n/user-language-options).", json_schema_extra={ "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, - "frontend_options": [ - {"value": "de", "label": "Deutsch"}, - {"value": "en", "label": "Englisch"}, - {"value": "fr", "label": "Französisch"}, - {"value": "it", "label": "Italienisch"}, - ], + "frontend_options": "/api/i18n/user-language-options", "label": "Sprache", }, ) @@ -261,10 +256,9 @@ class User(PowerOnModel): @field_validator('language', mode='before') @classmethod def _normalizeLanguage(cls, v): - """Normalize language to valid ISO 639-1 code.""" + """Normalize to primary language subtag (2–8 letters); default remains ``de``.""" if v is None: return "de" - # Map common variations to standard codes langMap = { 'english': 'en', 'englisch': 'en', 'german': 'de', 'deutsch': 'de', @@ -274,11 +268,7 @@ class User(PowerOnModel): normalized = str(v).lower().strip() if normalized in langMap: return langMap[normalized] - # If already a valid code, return as-is - if normalized in ['de', 'en', 'fr', 'it']: - return normalized - # Default fallback - return "de" + return normalizePrimaryLanguageTag(normalized, "de") enabled: bool = Field( default=True, diff --git a/modules/datamodels/datamodelUtils.py b/modules/datamodels/datamodelUtils.py index e9b519d4..bdef6cb2 100644 --- a/modules/datamodels/datamodelUtils.py +++ b/modules/datamodels/datamodelUtils.py @@ -2,8 +2,9 @@ # All rights reserved. """Utility datamodels: Prompt, TextMultilingual.""" -import re as _re -from typing import Any, Dict, Optional +import json +from typing import Any, Dict + from pydantic import BaseModel, Field, field_validator from modules.datamodels.datamodelBase import PowerOnModel from modules.shared.i18nRegistry import i18nModel @@ -98,21 +99,6 @@ class TextMultilingual(BaseModel): return cls(xx=t) -_REPR_PATTERN = _re.compile(r"(\w+)='([^']*)'") - - -def _parseReprString(s: str) -> Optional[Dict[str, str]]: - """Parse a Pydantic repr string like "en='text' de=None fr=" into a dict.""" - matches = _REPR_PATTERN.findall(s) - if not matches: - return None - result = {} - for code, text in matches: - if len(code) <= 5 and text: - result[code] = text - return result if result else None - - def coerce_text_multilingual(val: Any) -> TextMultilingual: """Normalize str, dict, or TextMultilingual into a valid TextMultilingual instance.""" if isinstance(val, TextMultilingual): @@ -125,10 +111,13 @@ def coerce_text_multilingual(val: Any) -> TextMultilingual: cleaned["xx"] = cleaned.get("de") or next((v for v in cleaned.values() if v), "—") return TextMultilingual(**cleaned) if isinstance(val, str) and val.strip(): - parsed = _parseReprString(val) - if parsed: - if not parsed.get("xx"): - parsed["xx"] = parsed.get("de") or next((v for v in parsed.values() if v), val.strip()) - return TextMultilingual(**parsed) - return TextMultilingual.fromUniform(val) + s = val.strip() + if s.startswith("{") and s.endswith("}"): + try: + parsed = json.loads(s) + if isinstance(parsed, dict): + return coerce_text_multilingual(parsed) + except json.JSONDecodeError: + pass + return TextMultilingual.fromUniform(s) return TextMultilingual.fromUniform("—") diff --git a/modules/features/chatbot/mainChatbot.py b/modules/features/chatbot/mainChatbot.py index 79f970c6..b50fef79 100644 --- a/modules/features/chatbot/mainChatbot.py +++ b/modules/features/chatbot/mainChatbot.py @@ -191,8 +191,8 @@ def getChatStreamingHelper(): def __get_placeholder_user(): """Placeholder user for contexts that only need service resolution (e.g. ChatStreamingHelper).""" - from modules.datamodels.datamodelUam import User - return User(id="system", username="system", email=None, fullName="System Placeholder") + from modules.interfaces.interfaceDbApp import getRootInterface + return getRootInterface().currentUser def getEventManager(user, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py index d4faa18d..c9b4564e 100644 --- a/modules/features/commcoach/interfaceFeatureCommcoach.py +++ b/modules/features/commcoach/interfaceFeatureCommcoach.py @@ -13,7 +13,7 @@ from modules.datamodels.datamodelUam import User from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.timeUtils import getIsoTimestamp from modules.shared.configuration import APP_CONFIG -from modules.shared.i18nRegistry import t +from modules.shared.i18nRegistry import resolveText, t from .datamodelCommcoach import ( CoachingContext, CoachingContextStatus, @@ -414,16 +414,20 @@ def _calcGoalProgress(goalsRaw) -> Optional[int]: _LEVELS = [ - (50, 5, "master", t("Meister")), - (25, 4, "expert", t("Experte")), - (10, 3, "advanced", t("Fortgeschritten")), - (3, 2, "engaged", t("Engagiert")), + (50, 5, "master", "Meister"), + (25, 4, "expert", "Experte"), + (10, 3, "advanced", "Fortgeschritten"), + (3, 2, "engaged", "Engagiert"), ] +t("Meister") +t("Experte") +t("Fortgeschritten") +t("Engagiert") t("Einsteiger") def _calcLevel(totalSessions: int) -> Dict[str, Any]: - for threshold, number, code, _label in _LEVELS: + for threshold, number, code, labelKey in _LEVELS: if totalSessions >= threshold: - return {"number": number, "code": code, "label": t(_label), "totalSessions": totalSessions} - return {"number": 1, "code": "beginner", "label": t("Einsteiger"), "totalSessions": totalSessions} + return {"number": number, "code": code, "label": resolveText(labelKey), "totalSessions": totalSessions} + return {"number": 1, "code": "beginner", "label": resolveText("Einsteiger"), "totalSessions": totalSessions} diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index 90789ec6..acbd62a6 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -236,9 +236,9 @@ def _seedBuiltinPersonas(): try: from .serviceCommcoachPersonas import seedBuiltinPersonas from .interfaceFeatureCommcoach import getInterface - from modules.datamodels.datamodelUam import User + from modules.interfaces.interfaceDbApp import getRootInterface - systemUser = User(id="system", username="system", email="system@poweron.swiss") + systemUser = getRootInterface().currentUser interface = getInterface(systemUser) seedBuiltinPersonas(interface) except Exception as e: diff --git a/modules/features/commcoach/serviceCommcoachGamification.py b/modules/features/commcoach/serviceCommcoachGamification.py index 5aa796b7..180706de 100644 --- a/modules/features/commcoach/serviceCommcoachGamification.py +++ b/modules/features/commcoach/serviceCommcoachGamification.py @@ -7,7 +7,7 @@ Checks and awards badges after each session completion. import logging from typing import Dict, Any, List, Optional -from modules.shared.i18nRegistry import t +from modules.shared.i18nRegistry import resolveText, t logger = logging.getLogger(__name__) @@ -80,9 +80,32 @@ BADGE_DEFINITIONS: Dict[str, Dict[str, Any]] = { } # Register all badge labels/descriptions at import time for i18n xx base set -for _bd in BADGE_DEFINITIONS.values(): - t(_bd["label"]) - t(_bd["description"]) +t("Erste Session") +t("Deine erste Coaching-Session abgeschlossen") +t("3-Tage-Serie") +t("3 Tage in Folge eine Session absolviert") +t("Wochenserie") +t("7 Tage in Folge eine Session absolviert") +t("Monatsserie") +t("30 Tage in Folge eine Session absolviert") +t("Engagiert") +t("5 Sessions abgeschlossen") +t("Fortgeschritten") +t("10 Sessions abgeschlossen") +t("Experte") +t("25 Sessions abgeschlossen") +t("Meister") +t("50 Sessions abgeschlossen") +t("Bestleistung") +t("Durchschnittsscore über 80 in einer Session") +t("Vielseitig") +t("3 verschiedene Coaching-Themen aktiv") +t("Rollenspieler") +t("Erste Roleplay-Session mit einer Persona abgeschlossen") +t("Ganzheitlich") +t("In allen 5 Kompetenz-Dimensionen bewertet") +t("Umsetzer") +t("10 Coaching-Aufgaben erledigt") async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId: str, @@ -141,8 +164,8 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId } newBadge = interface.awardBadge(badgeData) definition = BADGE_DEFINITIONS.get(badgeKey, {}) - newBadge["label"] = t(definition.get("label", badgeKey)) - newBadge["description"] = t(definition.get("description", "")) + newBadge["label"] = resolveText(definition.get("label", badgeKey)) + newBadge["description"] = resolveText(definition.get("description", "")) newBadge["icon"] = definition.get("icon", "star") awarded.append(newBadge) logger.info(f"Badge '{badgeKey}' awarded to user {userId}") @@ -154,5 +177,9 @@ def getBadgeDefinitions() -> Dict[str, Dict[str, Any]]: """Return all badge definitions for the frontend (labels resolved via i18n).""" resolved = {} for key, defn in BADGE_DEFINITIONS.items(): - resolved[key] = {**defn, "label": t(defn["label"]), "description": t(defn["description"])} + resolved[key] = { + **defn, + "label": resolveText(defn["label"]), + "description": resolveText(defn["description"]), + } return resolved diff --git a/modules/features/graphicalEditor/entryPoints.py b/modules/features/graphicalEditor/entryPoints.py index c63ada70..9ade2e96 100644 --- a/modules/features/graphicalEditor/entryPoints.py +++ b/modules/features/graphicalEditor/entryPoints.py @@ -36,10 +36,11 @@ def default_manual_entry_point() -> Dict[str, Any]: } -def _normalize_title(title: Any, preferredLang: Optional[str] = None) -> str: +def _normalize_title(title: Any) -> str: + """Extract a plain string from a title value for storage (not display).""" if isinstance(title, dict): - picked = (preferredLang and title.get(preferredLang)) or title.get("xx") or next(iter(title.values()), "") - return str(picked).strip() if picked is not None else "" + picked = title.get("xx") or next((v for v in title.values() if v), None) + return str(picked).strip() if picked else "Start" if isinstance(title, str) and title.strip(): return title.strip() return "Start" diff --git a/modules/features/graphicalEditor/nodeRegistry.py b/modules/features/graphicalEditor/nodeRegistry.py index 81cce9c7..1af0beb7 100644 --- a/modules/features/graphicalEditor/nodeRegistry.py +++ b/modules/features/graphicalEditor/nodeRegistry.py @@ -10,13 +10,14 @@ from typing import Dict, List, Any from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES +from modules.shared.i18nRegistry import normalizePrimaryLanguageTag logger = logging.getLogger(__name__) def getNodeTypes( services: Any = None, - language: str = "en", + language: str = "de", ) -> List[Dict[str, Any]]: """ Return static node types. No dynamic I/O derivation from methodDiscovery. @@ -25,27 +26,42 @@ def getNodeTypes( return list(STATIC_NODE_TYPES) +def _pickFromLangMap(d: Any, lang: str) -> Any: + """Resolve multilingual dict: ``lang`` → ``xx`` → ``de`` → ``en`` → first non-empty value.""" + if not isinstance(d, dict) or not d: + return None + for k in (lang, "xx", "de", "en"): + v = d.get(k) + if v is not None and v != "": + return v + for v in d.values(): + if v is not None and v != "": + return v + return None + + def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]: """Apply language to label/description/parameters. Keep inputPorts/outputPorts.""" - lang = language if language in ("en", "de", "fr") else "en" + lang = normalizePrimaryLanguageTag(language, "en") out = dict(node) for key in list(out.keys()): if key.startswith("_"): del out[key] if isinstance(node.get("label"), dict): - out["label"] = node["label"].get(lang, node["label"].get("en", str(node["label"]))) + out["label"] = _pickFromLangMap(node["label"], lang) or node.get("id", "") if isinstance(node.get("description"), dict): - out["description"] = node["description"].get(lang, node["description"].get("en", str(node["description"]))) + out["description"] = _pickFromLangMap(node["description"], lang) or "" ol = node.get("outputLabels") if isinstance(ol, dict) and ol: first = next(iter(ol.values()), None) if isinstance(first, (list, tuple)): - out["outputLabels"] = ol.get(lang, ol.get("en", list(first))) + picked = _pickFromLangMap(ol, lang) + out["outputLabels"] = picked if picked is not None else list(first) params = [] for p in node.get("parameters", []): pc = dict(p) if isinstance(p.get("description"), dict): - pc["description"] = p["description"].get(lang, p["description"].get("en", str(p.get("description", "")))) + pc["description"] = _pickFromLangMap(p["description"], lang) or str(p.get("description", "")) params.append(pc) out["parameters"] = params return out @@ -53,7 +69,7 @@ def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]: def getNodeTypesForApi( services: Any, - language: str = "en", + language: str = "de", ) -> Dict[str, Any]: """ API-ready response: nodeTypes with localized strings, plus categories, portTypeCatalog, systemVariables. diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py index c7d6e1dd..d9ae2792 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/features/graphicalEditor/portTypes.py @@ -14,6 +14,8 @@ from typing import Any, Callable, Dict, List, Optional from pydantic import BaseModel, Field +from modules.shared.i18nRegistry import resolveText + logger = logging.getLogger(__name__) @@ -480,11 +482,9 @@ def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]: for f in fields_param: if isinstance(f, dict) and f.get("name"): _lab = f.get("label") - _desc = ( - str(_lab.get("xx") or next(iter(_lab.values()), "") or f["name"]) - if isinstance(_lab, dict) - else str(_lab if _lab is not None else f["name"]) - ) + _desc = resolveText(_lab) if _lab is not None else f["name"] + if not _desc.strip(): + _desc = f["name"] portFields.append(PortField( name=f["name"], type=f.get("type", "str"), diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py index 91c907b5..7f2139e5 100644 --- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py @@ -26,21 +26,12 @@ from modules.workflows.automation2.runEnvelope import ( normalize_run_envelope, ) from modules.features.graphicalEditor.entryPoints import find_invocation -from modules.shared.i18nRegistry import apiRouteContext +from modules.shared.i18nRegistry import apiRouteContext, resolveText routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor") logger = logging.getLogger(__name__) -def _pickInvocationTitleLabel(title: Any, requestLang: Optional[str]) -> str: - if isinstance(title, str): - return title - if isinstance(title, dict) and title: - picked = (requestLang and title.get(requestLang)) or title.get("xx") or next(iter(title.values()), "") - return str(picked) if picked else "" - return "" - - def _build_execute_run_envelope( body: Dict[str, Any], workflow: Optional[Dict[str, Any]], @@ -80,7 +71,7 @@ def _build_execute_run_envelope( } trig = trig_map.get(kind, "manual") title = inv.get("title") or {} - label = _pickInvocationTitleLabel(title, requestLang) + label = resolveText(title) base = default_run_envelope( trig, entry_point_id=inv.get("id"), @@ -1052,10 +1043,7 @@ async def post_workflow_webhook( discoverMethods(services) title = inv.get("title") or {} - label = _pickInvocationTitleLabel( - title, - getattr(context.user, "language", None) if context.user else None, - ) + label = resolveText(title) pl = body if isinstance(body, dict) else {} base = default_run_envelope( "webhook", @@ -1113,10 +1101,7 @@ async def post_workflow_form_submit( discoverMethods(services) title = inv.get("title") or {} - label = _pickInvocationTitleLabel( - title, - getattr(context.user, "language", None) if context.user else None, - ) + label = resolveText(title) pl = body if isinstance(body, dict) else {} base = default_run_envelope( "form", diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index c2823a85..d316bde2 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -1252,18 +1252,16 @@ async def postTranscript( config = _getInstanceConfig(instanceId) # Load original user context from session - from modules.datamodels.datamodelUam import User - - systemUser = User(id="system", username="system", email="system@poweron.swiss") - sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId) + rootInterface = getRootInterface() + rootUser = rootInterface.currentUser + sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId) session = sessionInterface.getSession(sessionId) mandateId = session.get("mandateId") if session else None startedByUserId = session.get("startedByUserId") if session else None - rootInterface = getRootInterface() originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None if not originalUser: - originalUser = systemUser + originalUser = rootUser # Process transcript through the service pipeline from .service import TeamsbotService @@ -1308,18 +1306,16 @@ async def postBotStatus( try: config = _getInstanceConfig(instanceId) - from modules.datamodels.datamodelUam import User - - systemUser = User(id="system", username="system", email="system@poweron.swiss") - sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId) + rootInterface = getRootInterface() + rootUser = rootInterface.currentUser + sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId) session = sessionInterface.getSession(sessionId) mandateId = session.get("mandateId") if session else None startedByUserId = session.get("startedByUserId") if session else None - rootInterface = getRootInterface() originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None if not originalUser: - originalUser = systemUser + originalUser = rootUser from .service import TeamsbotService service = TeamsbotService(originalUser, mandateId, instanceId, config) @@ -1361,22 +1357,20 @@ async def botWebsocket( # Load the original user who started the session (has RBAC roles in mandate) # Bot callbacks have no HTTP auth, so we reconstruct the user context from the session record. - from modules.datamodels.datamodelUam import User from modules.interfaces.interfaceDbApp import getRootInterface - systemUser = User(id="system", username="system", email="system@poweron.swiss") - sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId) + rootInterface = getRootInterface() + rootUser = rootInterface.currentUser + sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId) session = sessionInterface.getSession(sessionId) mandateId = session.get("mandateId") if session else None startedByUserId = session.get("startedByUserId") if session else None - # Look up the original user (getRootInterface uses admin context, can load any user) - rootInterface = getRootInterface() originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None if not originalUser: - logger.warning(f"Could not load original user {startedByUserId}, falling back to system user") - originalUser = systemUser + logger.warning(f"Could not load original user {startedByUserId}, falling back to root user") + originalUser = rootUser # Build effective config with the session's actual bot name. # The session stores the resolved bot name (from system bot or user override). diff --git a/modules/features/trustee/accounting/accountingRegistry.py b/modules/features/trustee/accounting/accountingRegistry.py index 707d8fbb..b5ce6e80 100644 --- a/modules/features/trustee/accounting/accountingRegistry.py +++ b/modules/features/trustee/accounting/accountingRegistry.py @@ -10,7 +10,7 @@ import os from typing import Dict, List, Optional from .accountingConnectorBase import BaseAccountingConnector -from modules.shared.i18nRegistry import t +from modules.shared.i18nRegistry import resolveText logger = logging.getLogger(__name__) @@ -62,11 +62,11 @@ class AccountingRegistry: fields = [] for f in connector.getRequiredConfigFields(): fd = f.model_dump() - fd["label"] = t(f.label) + fd["label"] = resolveText(f.label) fields.append(fd) result.append({ "connectorType": connectorType, - "label": t(connector.getConnectorLabel()), + "label": resolveText(connector.getConnectorLabel()), "configFields": fields, }) return result diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index 91de0b60..3233523b 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -151,12 +151,7 @@ def getQuickActions( if role and role.roleLabel: userRoleLabels.add(role.roleLabel) - def _resolveText(multilingual, lang: str) -> str: - if isinstance(multilingual, str): - return multilingual - if isinstance(multilingual, dict): - return multilingual.get(lang) or multilingual.get("xx") or next(iter(multilingual.values()), "") - return "" + from modules.shared.i18nRegistry import resolveText filteredActions = [] for action in QUICK_ACTIONS: @@ -166,8 +161,8 @@ def getQuickActions( if context.hasSysAdminRole or required.intersection(userRoleLabels): resolved = { "id": action["id"], - "label": _resolveText(action.get("label", {}), language), - "description": _resolveText(action.get("description", {}), language), + "label": resolveText(action.get("label", {})), + "description": resolveText(action.get("description", {})), "icon": action.get("icon", ""), "color": action.get("color", ""), "category": action.get("category", ""), @@ -178,14 +173,14 @@ def getQuickActions( if resolved["actionType"] == "agentPrompt" and "config" in resolved: cfg = dict(resolved["config"]) if "uploadHint" in cfg: - cfg["uploadHint"] = _resolveText(cfg["uploadHint"], language) + cfg["uploadHint"] = resolveText(cfg["uploadHint"]) resolved["config"] = cfg filteredActions.append(resolved) filteredActions.sort(key=lambda a: a["sortOrder"]) resolvedCategories = [ - {"id": c["id"], "label": _resolveText(c.get("label", {}), language), "sortOrder": c.get("sortOrder", 99)} + {"id": c["id"], "label": resolveText(c.get("label", {})), "sortOrder": c.get("sortOrder", 99)} for c in QUICK_ACTION_CATEGORIES ] diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 16235cd3..08216e56 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -29,7 +29,7 @@ from modules.interfaces.interfaceAiObjects import AiObjects from modules.serviceCenter.core.serviceStreaming import get_event_manager from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit from modules.shared.timeUtils import parseTimestamp -from modules.shared.i18nRegistry import apiRouteContext, t +from modules.shared.i18nRegistry import apiRouteContext, resolveText routeApiMsg = apiRouteContext("routeFeatureWorkspace") logger = logging.getLogger(__name__) @@ -1467,7 +1467,7 @@ async def listFeatureConnectionTables( node = { "objectKey": obj.get("objectKey", ""), "tableName": meta.get("table", ""), - "label": t(obj.get("label", "")), + "label": resolveText(obj.get("label", "")), "fields": meta.get("fields", []), } if meta.get("isParent"): diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 7e0dd234..d4cb5b08 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -666,7 +666,7 @@ class AppObjects: password: str = None, email: str = None, fullName: str = None, - language: str = "en", + language: str = "de", enabled: bool = True, authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL, externalId: str = None, diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py index bb9ead89..943acdb5 100644 --- a/modules/interfaces/interfaceFeatures.py +++ b/modules/interfaces/interfaceFeatures.py @@ -17,6 +17,7 @@ from modules.datamodels.datamodelFeatures import Feature, FeatureInstance from modules.datamodels.datamodelRbac import Role, AccessRule from modules.datamodels.datamodelUtils import coerce_text_multilingual from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.shared.i18nRegistry import resolveText logger = logging.getLogger(__name__) @@ -241,9 +242,9 @@ class FeatureInterface: return 0 from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface - from modules.auth.authModels import SystemUser - systemUser = SystemUser() - geInterface = getGraphicalEditorInterface(systemUser, mandateId, instanceId) + from modules.interfaces.interfaceDbApp import getRootInterface + rootUser = getRootInterface().currentUser + geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId) copied = 0 for template in templateWorkflows: @@ -251,11 +252,7 @@ class FeatureInterface: graphJson = graphJson.replace("{{featureInstanceId}}", instanceId) graph = json.loads(graphJson) - labelDict = template.get("label", {}) - if isinstance(labelDict, dict): - label = labelDict.get("xx") or next(iter(labelDict.values()), "") or str(labelDict) - else: - label = str(labelDict) + label = resolveText(template.get("label")) geInterface.createWorkflow({ "label": label, diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index 13b47c28..37118426 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -27,24 +27,12 @@ from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.security.rbacCatalog import getCatalogService from modules.routes.routeNotifications import create_access_change_notification -from modules.shared.i18nRegistry import apiRouteContext +from modules.shared.i18nRegistry import apiRouteContext, resolveText routeApiMsg = apiRouteContext("routeAdminFeatures") logger = logging.getLogger(__name__) -def _featureLabelPlain(label: Union[str, Dict[str, str], None], fallback: str, requestLang: Optional[str] = None) -> str: - """Catalog feature label as a single display/i18n key string.""" - if isinstance(label, str) and label.strip(): - return label - if isinstance(label, dict): - picked = (requestLang and label.get(requestLang)) or label.get("xx") or next(iter(label.values()), "") - if picked: - return str(picked) - return fallback - return fallback - - def _feature_instance_display_name(instance: Any) -> str: if instance is None: return "" @@ -197,11 +185,7 @@ def get_my_feature_instances( featureDef = catalogService.getFeatureDefinition(instance.featureCode) featuresMap[featureKey] = { "code": instance.featureCode, - "label": _featureLabelPlain( - featureDef.get("label") if featureDef else None, - instance.featureCode, - getattr(context.user, "language", None), - ), + "label": resolveText(featureDef.get("label") if featureDef else None), "icon": featureDef.get("icon", "folder") if featureDef else "folder", "instances": [], "_mandateId": mandateId # Temporary for grouping diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index 92ceaf18..9b756152 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -23,23 +23,12 @@ from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.interfaces.interfaceDbApp import getInterface, getRootInterface -from modules.shared.i18nRegistry import apiRouteContext, t, _getLanguage +from modules.shared.i18nRegistry import apiRouteContext, t, resolveText routeApiMsg = apiRouteContext("routeAdminRbacRules") # Configure logger logger = logging.getLogger(__name__) - -def _resolveTextMultilingual(value) -> str: - """Resolve a TextMultilingual dict to a single string for the current request language. - Falls back to xx (source text), then any available value.""" - if isinstance(value, str): - return value - if isinstance(value, dict): - lang = _getLanguage() - return value.get(lang) or value.get("xx") or next(iter(value.values()), "") - return str(value) if value else "" - router = APIRouter( prefix="/api/rbac", tags=["RBAC"], @@ -922,7 +911,7 @@ def list_roles( result.append({ "id": role.id, "roleLabel": role.roleLabel, - "description": _resolveTextMultilingual(role.description), + "description": resolveText(role.description), "mandateId": role.mandateId, "featureInstanceId": role.featureInstanceId, "featureCode": role.featureCode, @@ -1051,7 +1040,7 @@ def get_roles_filter_values( result.append({ "id": role.id, "roleLabel": role.roleLabel, - "description": _resolveTextMultilingual(role.description), + "description": resolveText(role.description), "mandateId": role.mandateId, "featureInstanceId": role.featureInstanceId, "featureCode": role.featureCode, @@ -1168,7 +1157,7 @@ def get_role( return { "id": role.id, "roleLabel": role.roleLabel, - "description": _resolveTextMultilingual(role.description), + "description": resolveText(role.description), "mandateId": role.mandateId, "featureInstanceId": role.featureInstanceId, "featureCode": role.featureCode, @@ -1369,12 +1358,8 @@ def getCatalogObjects( def _resolveLabels(objects: list) -> list: for obj in objects: - raw = obj.get("label") - if isinstance(raw, str): - obj["label"] = t(raw) - elif isinstance(raw, dict): - key = raw.get("xx") or next(iter(raw.values()), "") or obj.get("objectKey", "") - obj["label"] = t(key) if key else f"[{obj.get('objectKey', '?')}]" + resolved = resolveText(obj.get("label")) + obj["label"] = resolved if resolved else f"[{obj.get('objectKey', '?')}]" return objects if context: diff --git a/modules/routes/routeAdminUserAccessOverview.py b/modules/routes/routeAdminUserAccessOverview.py index 4b5e1212..59bf415f 100644 --- a/modules/routes/routeAdminUserAccessOverview.py +++ b/modules/routes/routeAdminUserAccessOverview.py @@ -24,24 +24,13 @@ from modules.datamodels.datamodelMembership import ( ) from modules.datamodels.datamodelFeatures import FeatureInstance, Feature from modules.interfaces.interfaceDbApp import getRootInterface -from modules.shared.i18nRegistry import apiRouteContext, t, _getLanguage +from modules.shared.i18nRegistry import apiRouteContext, t, resolveText routeApiMsg = apiRouteContext("routeAdminUserAccessOverview") # Configure logger logger = logging.getLogger(__name__) -def _resolveTextMultilingual(value) -> str: - """Resolve a TextMultilingual dict to a single string for the current request language. - Falls back to xx (source text), then any available value.""" - if isinstance(value, str): - return value - if isinstance(value, dict): - lang = _getLanguage() - return value.get(lang) or value.get("xx") or next(iter(value.values()), "") - return str(value) if value else "" - - router = APIRouter( prefix="/api/admin/user-access-overview", tags=["Admin User Access Overview"], @@ -309,7 +298,7 @@ def getUserAccessOverview( roleInfo = { "id": roleId, "roleLabel": role.roleLabel, - "description": _resolveTextMultilingual(role.description), + "description": resolveText(role.description), "scope": scope, "scopePriority": _getRoleScopePriority(scope), "mandateId": role.mandateId, @@ -345,7 +334,7 @@ def getUserAccessOverview( # Get feature info using interface method featureCode = instance.featureCode feature = interface.getFeatureByCode(featureCode) - featureLabel = t(feature.label) if feature and feature.label else "" + featureLabel = resolveText(feature.label) if feature and feature.label else "" # Get roles for this FeatureAccess using interface method instanceRoleIds = interface.getRoleIdsForFeatureAccess(faId) @@ -359,7 +348,7 @@ def getUserAccessOverview( roleInfo = { "id": roleId, "roleLabel": role.roleLabel, - "description": _resolveTextMultilingual(role.description), + "description": resolveText(role.description), "scope": scope, "scopePriority": _getRoleScopePriority(scope), "mandateId": role.mandateId, diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index 07c3d81b..28d11392 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -24,7 +24,7 @@ from modules.auth import limiter, getRequestContext, RequestContext from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict -from modules.shared.i18nRegistry import apiRouteContext +from modules.shared.i18nRegistry import apiRouteContext, resolveText routeApiMsg = apiRouteContext("routeDataUsers") # Configure logger @@ -87,9 +87,9 @@ def _extractDistinctValues( elif isinstance(val, (int, float)): values.add(str(val)) elif isinstance(val, dict): - text = (requestLang and val.get(requestLang)) or val.get("xx") or next(iter(val.values()), None) + text = resolveText(val, requestLang) if text: - values.add(str(text)) + values.add(text) else: values.add(str(val)) return sorted(values, key=lambda v: v.lower()) @@ -606,7 +606,7 @@ class CreateUserRequest(BaseModel): username: str email: Optional[str] = None fullName: Optional[str] = None - language: str = "en" + language: str = "de" enabled: bool = True isSysAdmin: bool = False password: Optional[str] = None diff --git a/modules/routes/routeI18n.py b/modules/routes/routeI18n.py index 070d9c90..41e9645c 100644 --- a/modules/routes/routeI18n.py +++ b/modules/routes/routeI18n.py @@ -508,6 +508,24 @@ async def list_language_codes(): return sorted(out, key=lambda x: (not x.get("isDefault"), x["code"])) +@router.get("/user-language-options") +async def list_user_language_options(): + """Select options for User.language: all UiLanguageSets except ``xx`` (basis set). + + Returns ``[{ \"value\": code, \"label\": name }, ...]`` for FormGenerator ``frontend_options`` URL. + """ + db = _publicMgmtDb() + rows = db.getRecordset(UiLanguageSet) + out: List[Dict[str, str]] = [] + for r in rows: + code = r.get("id") + if not code or code == "xx": + continue + lbl = (r.get("label") or "").strip() or code + out.append({"value": code, "label": lbl}) + return sorted(out, key=lambda x: (x.get("label") or x["value"]).lower()) + + @router.get("/sets/{code}") async def get_language_set(code: str): db = _publicMgmtDb() diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py index d60bd1df..04517d9b 100644 --- a/modules/routes/routeStore.py +++ b/modules/routes/routeStore.py @@ -23,23 +23,11 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.security.rbacCatalog import getCatalogService from modules.security.rbac import RbacClass from modules.security.rootAccess import getRootDbAppConnector -from modules.shared.i18nRegistry import apiRouteContext +from modules.shared.i18nRegistry import apiRouteContext, resolveText routeApiMsg = apiRouteContext("routeStore") logger = logging.getLogger(__name__) - -def _storeLabelText(label: Union[str, Dict[str, str], None], fallback: str, requestLang: Optional[str] = None) -> str: - """Normalize catalog label to a single display/i18n key string.""" - if isinstance(label, str) and label.strip(): - return label - if isinstance(label, dict): - picked = (requestLang and label.get(requestLang)) or label.get("xx") or next(iter(label.values()), "") - if picked: - return str(picked) - return fallback - return fallback - router = APIRouter( prefix="/api/store", tags=["Store"], @@ -301,9 +289,9 @@ def listStoreFeatures( instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds) result.append(StoreFeatureResponse( featureCode=featureCode, - label=_storeLabelText(featureDef.get("label"), featureCode, getattr(context.user, "language", None)), + label=resolveText(featureDef.get("label")), icon=featureDef.get("icon", "mdi-puzzle"), - description=_storeLabelText(featureDef.get("description"), "", getattr(context.user, "language", None)), + description=resolveText(featureDef.get("description")), instances=instances, canActivate=True, )) @@ -391,7 +379,7 @@ def activateStoreFeature( # ── 3. Provision instance ─────────────────────────────────────── featureInterface = getFeatureInterface(db) - featureLabel = _storeLabelText(featureDef.get("label"), featureCode, getattr(context.user, "language", None)) + featureLabel = resolveText(featureDef.get("label")) instance = featureInterface.createFeatureInstance( featureCode=featureCode, mandateId=mandateId, diff --git a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py index d1726fcd..43dbb9d7 100644 --- a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py +++ b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py @@ -21,6 +21,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ( ) from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider +from modules.shared.i18nRegistry import resolveText logger = logging.getLogger(__name__) @@ -305,12 +306,7 @@ def _buildSchemaContext( meta = obj.get("meta", {}) tbl = meta.get("table", "?") fields = meta.get("fields", []) - label = obj.get("label", {}) - if isinstance(label, dict): - picked = (requestLang and label.get(requestLang)) or label.get("xx") or next(iter(label.values()), "") - labelStr = str(picked) if picked else tbl - else: - labelStr = str(label).strip() if isinstance(label, str) and str(label).strip() else tbl + labelStr = resolveText(obj.get("label"), requestLang) tableNames.append(tbl) block = f" Table: {tbl} ({labelStr})" if fields: diff --git a/modules/serviceCenter/services/serviceAi/subStructureFilling.py b/modules/serviceCenter/services/serviceAi/subStructureFilling.py index 3795f44d..b31bc32d 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureFilling.py +++ b/modules/serviceCenter/services/serviceAi/subStructureFilling.py @@ -2051,7 +2051,7 @@ class StructureFiller: contentPartInstructions: Dict[str, Any], contentParts: List[ContentPart], userPrompt: str, - language: str = "en", + language: str = "de", outputFormat: str = "txt" ) -> str: """Baue Prompt für Chapter-Sections-Struktur-Generierung, querying renderer for accepted section types.""" @@ -2187,7 +2187,7 @@ Return only valid JSON. Do not include any explanatory text outside the JSON. allSections: Optional[List[Dict[str, Any]]] = None, sectionIndex: Optional[int] = None, isAggregation: bool = False, - language: str = "en", + language: str = "de", outputFormat: str = "txt", preExtractedText: Optional[str] = None ) -> tuple[str, str]: diff --git a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py index 72127c92..3d531756 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py +++ b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py @@ -14,6 +14,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.i18nRegistry import normalizePrimaryLanguageTag logger = logging.getLogger(__name__) @@ -262,18 +263,17 @@ CRITICAL: # Validation 3.5 & 3.6: Document language # Use validated currentUserLanguage (always valid, validated during user intention analysis) # Access via _getUserLanguage() which uses self.services.currentUserLanguage - userPromptLanguage = self._getUserLanguage() # Uses validated currentUserLanguage infrastructure - - if "language" not in doc or not isinstance(doc["language"], str) or len(doc["language"]) != 2: - # AI didn't return language or invalid format - use validated currentUserLanguage + userPromptLanguage = normalizePrimaryLanguageTag(self._getUserLanguage(), "de") + + raw_lang = doc.get("language") + if not isinstance(raw_lang, str) or not str(raw_lang).strip(): doc["language"] = userPromptLanguage if "language" not in doc: logger.warning(f"Document {doc.get('id')} missing language - using currentUserLanguage: {userPromptLanguage}") else: - logger.warning(f"Document {doc.get('id')} has invalid language format from AI: {doc['language']}, using currentUserLanguage") + logger.warning(f"Document {doc.get('id')} has invalid language format from AI: {doc.get('language')}, using currentUserLanguage") else: - # AI returned valid language format - normalize - doc["language"] = doc["language"].lower().strip()[:2] + doc["language"] = normalizePrimaryLanguageTag(str(raw_lang), userPromptLanguage) logger.debug(f"Document {doc.get('id')} using AI-determined language: {doc['language']}") # Validation 3.7: Document missing 'chapters' field diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py index b36addf5..898ca85f 100644 --- a/modules/shared/attributeUtils.py +++ b/modules/shared/attributeUtils.py @@ -45,77 +45,53 @@ def _getModelLabelEntry(modelName: str) -> Dict[str, Any]: return i18nModelLabels.get(modelName) or {} -def getModelLabels(modelName: str, language: str = "en") -> Dict[str, str]: +def getModelLabels(modelName: str) -> Dict[str, str]: """Get labels for a model's attributes in the specified language. - Reads @i18nModel registration (German base strings); non-German languages use the i18n cache. - Attribute values are strings; dict-shaped entries are still accepted for unusual callers. + Reads @i18nModel registration (German base strings); resolves via resolveText(). """ modelData = _getModelLabelEntry(modelName) attributeLabels = modelData.get("attributes", {}) + from modules.shared.i18nRegistry import resolveText result: Dict[str, str] = {} for attr, translations in attributeLabels.items(): - if isinstance(translations, dict): - germanKey = translations.get("xx") or next(iter(translations.values()), attr) - result[attr] = _resolveLabel(germanKey, language) - elif isinstance(translations, str): - result[attr] = _resolveLabel(translations, language) - else: - result[attr] = f"[{attr}]" + resolved = resolveText(translations) + result[attr] = resolved if resolved else f"[{attr}]" return result -def _resolveLabel(germanText: str, language: str) -> str: - """Resolve a German base label to the requested language via i18n cache. - Returns [germanText] when no translation exists so missing keys are visible in the UI.""" - if language == "de": - return germanText - try: - from modules.shared.i18nRegistry import _CACHE - return _CACHE.get(language, {}).get(germanText, f"[{germanText}]") - except ImportError: - return germanText - - -def _resolveOptionLabels(options, userLanguage: str): - """Resolve frontend_options label values to the requested language.""" +def _resolveOptionLabels(options): + """Resolve frontend_options label values via resolveText().""" if not isinstance(options, list): return options + from modules.shared.i18nRegistry import resolveText for opt in options: if not isinstance(opt, dict) or "label" not in opt: continue - raw = opt["label"] - if isinstance(raw, dict): - germanKey = raw.get("xx") or next(iter(raw.values()), str(opt.get("value", ""))) - opt["label"] = _resolveLabel(germanKey, userLanguage) - elif isinstance(raw, str): - opt["label"] = _resolveLabel(raw, userLanguage) + opt["label"] = resolveText(opt["label"]) return options -def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Dict[str, str]: +def _mergedAttributeLabels(modelClass: Type[BaseModel]) -> Dict[str, str]: """Merge attribute labels from model MRO (base classes first, subclass overrides).""" try: baseIdx = modelClass.__mro__.index(BaseModel) except ValueError: - return getModelLabels(modelClass.__name__, userLanguage) + return getModelLabels(modelClass.__name__) merged: Dict[str, str] = {} for cls in reversed(modelClass.__mro__[:baseIdx]): - merged.update(getModelLabels(cls.__name__, userLanguage)) + merged.update(getModelLabels(cls.__name__)) return merged -def getModelLabel(modelName: str, language: str = "de") -> str: - """Get the label for a model in the specified language (see getModelLabels).""" +def getModelLabel(modelName: str) -> str: + """Get the label for a model via resolveText().""" modelData = _getModelLabelEntry(modelName) modelLabel = modelData.get("model", {}) - if isinstance(modelLabel, dict): - germanKey = modelLabel.get("xx") or next(iter(modelLabel.values()), modelName) - return _resolveLabel(germanKey, language) - elif isinstance(modelLabel, str): - return _resolveLabel(modelLabel, language) - return f"[{modelName}]" + from modules.shared.i18nRegistry import resolveText + resolved = resolveText(modelLabel) + return resolved if resolved else f"[{modelName}]" def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguage: str = "en") -> Dict[str, Any]: @@ -134,8 +110,8 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag attributes = [] model_name = modelClass.__name__ - labels = _mergedAttributeLabels(modelClass, userLanguage) - model_label = getModelLabel(model_name, userLanguage) + labels = _mergedAttributeLabels(modelClass) + model_label = getModelLabel(model_name) # Pydantic v2 only fields = modelClass.model_fields @@ -273,7 +249,7 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag "visible": frontend_visible, "order": len(attributes), "readonly": frontend_readonly, - "options": _resolveOptionLabels(frontend_options, userLanguage), + "options": _resolveOptionLabels(frontend_options), "default": field_default, } diff --git a/modules/shared/i18nRegistry.py b/modules/shared/i18nRegistry.py index 7b8c2baf..f681b19f 100644 --- a/modules/shared/i18nRegistry.py +++ b/modules/shared/i18nRegistry.py @@ -80,6 +80,50 @@ def t(key: str, context: str = "api", value: str = "") -> str: 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. @@ -155,6 +199,23 @@ def _getLanguage() -> str: 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 # --------------------------------------------------------------------------- @@ -270,15 +331,10 @@ def _registerFeatureUiLabels(): _REGISTRY[fl] = _I18nRegistryEntry(context="nav", value="") added += 1 for uiObj in getattr(mod, "UI_OBJECTS", []) or []: - lab = uiObj.get("label") - if isinstance(lab, str) and lab and lab not in _REGISTRY: - _REGISTRY[lab] = _I18nRegistryEntry(context="nav", value="") + base = _extractRegistrySourceText(uiObj.get("label")) + if base and base not in _REGISTRY: + _REGISTRY[base] = _I18nRegistryEntry(context="nav", value="") added += 1 - elif isinstance(lab, dict): - base = lab.get("xx") or next(iter(lab.values()), "") - 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) diff --git a/modules/workflows/automation2/subAutomation2Schedule.py b/modules/workflows/automation2/subAutomation2Schedule.py index 01f1efe8..3f42d8f6 100644 --- a/modules/workflows/automation2/subAutomation2Schedule.py +++ b/modules/workflows/automation2/subAutomation2Schedule.py @@ -9,6 +9,7 @@ import logging from typing import Any, Dict, Optional from modules.shared.eventManagement import eventManager +from modules.shared.i18nRegistry import resolveText # Main loop reference for scheduling async work from job executor (may run in thread) _main_loop = None @@ -214,13 +215,7 @@ def _create_schedule_handler( title = (inv or {}).get("title") or {} requestLang: Optional[str] = getattr(event_user, "language", None) - if isinstance(title, dict): - picked = (requestLang and title.get(requestLang)) or title.get("xx") or next(iter(title.values()), "") - label = str(picked) if picked else "" - elif isinstance(title, str): - label = title - else: - label = "" + label = resolveText(title, requestLang) if title else "" run_env = default_run_envelope( "schedule", diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py index 73816da0..b9aafcd3 100644 --- a/modules/workflows/methods/methodFile/actions/create.py +++ b/modules/workflows/methods/methodFile/actions/create.py @@ -7,6 +7,7 @@ from typing import Dict, Any from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson +from modules.shared.i18nRegistry import normalizePrimaryLanguageTag logger = logging.getLogger(__name__) @@ -84,7 +85,10 @@ async def create(self, parameters: Dict[str, Any]) -> ActionResult: outputFormat = (parameters.get("outputFormat") or "docx").strip().lower().lstrip(".") title = (parameters.get("title") or "Document").strip() templateName = parameters.get("templateName") - language = (parameters.get("language") or "de").strip()[:2] + language = normalizePrimaryLanguageTag( + str(parameters.get("language") or "de"), + "de", + ) try: structured_content = markdownToDocumentJson(context, title, language) diff --git a/modules/workflows/scheduler/mainScheduler.py b/modules/workflows/scheduler/mainScheduler.py index c869bb69..210784a2 100644 --- a/modules/workflows/scheduler/mainScheduler.py +++ b/modules/workflows/scheduler/mainScheduler.py @@ -14,6 +14,7 @@ import logging from typing import Any, Dict, Optional from modules.shared.eventManagement import eventManager +from modules.shared.i18nRegistry import resolveText logger = logging.getLogger(__name__) @@ -232,13 +233,7 @@ class WorkflowScheduler: title = (inv or {}).get("title") or {} requestLang: Optional[str] = getattr(eventUser, "language", None) - if isinstance(title, dict): - picked = (requestLang and title.get(requestLang)) or title.get("xx") or next(iter(title.values()), "") - label = str(picked) if picked else "" - elif isinstance(title, str): - label = title - else: - label = "" + label = resolveText(title, requestLang) if title else "" runEnv = default_run_envelope( "schedule",