563 lines
21 KiB
Python
563 lines
21 KiB
Python
# 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():
|
|
"""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 at boot time."""
|
|
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.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():
|
|
"""Boot hook: discover all i18n keys and write them into UiLanguageSet(xx)."""
|
|
_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),
|
|
)
|
|
|
|
|
|
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 ""
|
|
),
|
|
)
|