gateway/modules/routes/routeI18n.py
2026-04-09 00:21:37 +02:00

896 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Public and authenticated routes for UI language sets (DB-backed i18n).
Architecture:
- xx = base set (meta): key = German plaintext, value = UI context for AI
- All languages (incl. de) are AI-generated translations from xx
- AI translation pipeline uses context from xx to disambiguate translations
"""
from __future__ import annotations
import asyncio
import json
import logging
import math
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Request, UploadFile, status
from fastapi.responses import Response
from pydantic import BaseModel, Field
from modules.auth import getCurrentUser, requireSysAdminRole
from modules.connectors.connectorDbPostgre import _get_cached_connector
from modules.datamodels.datamodelAi import (
AiCallOptions,
AiCallRequest,
AiCallResponse,
OperationTypeEnum,
PriorityEnum,
)
from modules.datamodels.datamodelUiLanguage import I18nEntry, UiLanguageSet
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelNotification import NotificationType
from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface
from modules.routes.routeNotifications import _createNotification
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/i18n",
tags=["i18n"],
responses={404: {"description": "Not found"}},
)
_MIN_AI_BILLING_ESTIMATE_CHF = 0.01
_TRANSLATE_BATCH_SIZE = 80
_PROTECTED_CODES = frozenset({"xx"})
# ---------------------------------------------------------------------------
# ISO 639-1 label map (used when creating a language without explicit label)
# ---------------------------------------------------------------------------
_ISO_LABELS: Dict[str, str] = {
"de": "Deutsch", "en": "English", "fr": "Français", "it": "Italiano",
"es": "Español", "pt": "Português", "nl": "Nederlands", "pl": "Polski",
"cs": "Čeština", "sk": "Slovenčina", "sv": "Svenska", "no": "Norsk",
"da": "Dansk", "fi": "Suomi", "hu": "Magyar", "ro": "Română",
"bg": "Български", "hr": "Hrvatski", "sl": "Slovenščina", "et": "Eesti",
"lv": "Latviešu", "lt": "Lietuvių", "el": "Ελληνικά", "tr": "Türkçe",
"ru": "Русский", "uk": "Українська", "ar": "العربية", "he": "עברית",
"zh": "中文", "ja": "日本語", "ko": "한국어", "hi": "हिन्दी",
"th": "ไทย", "vi": "Tiếng Việt", "id": "Bahasa Indonesia", "ms": "Bahasa Melayu",
"tl": "Filipino", "sw": "Kiswahili", "af": "Afrikaans", "sq": "Shqip",
"am": "አማርኛ", "hy": "Հայերեն", "az": "Azərbaycan", "eu": "Euskara",
"be": "Беларуская", "bn": "বাংলা", "bs": "Bosanski", "ca": "Català",
"cy": "Cymraeg", "eo": "Esperanto", "fa": "فارسی", "ga": "Gaeilge",
"gl": "Galego", "gu": "ગુજરાતી", "ha": "Hausa", "is": "Íslenska",
"jv": "Basa Jawa", "ka": "ქართული", "kk": "Қазақ", "km": "ខ្មែរ",
"kn": "ಕನ್ನಡ", "ku": "Kurdî", "ky": "Кыргызча", "la": "Latina",
"lb": "Lëtzebuergesch", "lo": "ລາວ", "mk": "Македонски", "ml": "മലയാളം",
"mn": "Монгол", "mr": "मराठी", "mt": "Malti", "my": "မြန်မာ",
"ne": "नेपाली", "or": "ଓଡ଼ିଆ", "pa": "ਪੰਜਾਬੀ", "ps": "پښتو",
"si": "සිංහල", "so": "Soomaali", "sr": "Српски", "su": "Basa Sunda",
"ta": "தமிழ்", "te": "తెలుగు", "tg": "Тоҷикӣ", "tk": "Türkmen",
"ur": "اردو", "uz": "Oʻzbek", "yo": "Yorùbá", "zu": "isiZulu",
}
# ---------------------------------------------------------------------------
# DB helpers
# ---------------------------------------------------------------------------
def _publicMgmtDb():
return _get_cached_connector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase="poweron_management",
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId="__i18n_public__",
)
def _rowEntries(row: dict) -> List[dict]:
"""Read entries from a DB row, supporting both new (entries) and legacy (keys) format."""
entries = row.get("entries")
if isinstance(entries, list) and entries:
return entries
keys = row.get("keys")
if isinstance(keys, dict) and keys:
return [{"context": "ui", "key": k, "value": v} for k, v in keys.items()]
return []
def _entriesToKeyValueMap(entries: List[dict]) -> Dict[str, str]:
"""Convert entries list to a flat key->value map (for frontend consumption)."""
return {e["key"]: e.get("value", "") for e in entries if e.get("key")}
def _row_to_public(row: dict) -> dict:
entries = _rowEntries(row)
return {
"code": row["id"],
"label": row.get("label"),
"status": row.get("status"),
"entries": entries,
}
def _loadMasterXxEntries(db) -> List[dict]:
"""Load the xx base set entries from DB."""
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"})
if not rows:
return []
return _rowEntries(rows[0])
def _userMemberMandateIds(currentUser: User) -> List[str]:
from modules.interfaces.interfaceDbApp import getRootInterface
root = getRootInterface()
memberships = root.getUserMandates(str(currentUser.id))
out = []
for um in memberships:
mid = getattr(um, "mandateId", None) or (
um.get("mandateId") if isinstance(um, dict) else None
)
if mid:
out.append(str(mid))
return list(dict.fromkeys(out))
def _mandatePassesAiPoolBilling(currentUser: User, mandateId: str, userId: str) -> bool:
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
bi = getBillingInterface(currentUser, mandateId)
res = bi.checkBalance(mandateId, userId, _MIN_AI_BILLING_ESTIMATE_CHF)
return bool(res.allowed)
# ---------------------------------------------------------------------------
# AI Translation helpers
# ---------------------------------------------------------------------------
_aiObjectsSingleton = None
async def _getAiObjects():
global _aiObjectsSingleton
if _aiObjectsSingleton is None:
from modules.interfaces.interfaceAiObjects import AiObjects
_aiObjectsSingleton = await AiObjects.create()
return _aiObjectsSingleton
def _makeBillingCallback(currentUser: User, mandateId: str):
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService
billingService = getBillingService(currentUser, mandateId)
def _cb(response: AiCallResponse) -> None:
if not response or getattr(response, "errorCount", 0) > 0:
return
basePriceCHF = getattr(response, "priceCHF", 0.0)
if not basePriceCHF or basePriceCHF <= 0:
return
provider = getattr(response, "provider", None) or "unknown"
modelName = getattr(response, "modelName", None) or "unknown"
try:
billingService.recordUsage(
priceCHF=basePriceCHF,
aicoreProvider=provider,
aicoreModel=modelName,
description=f"i18n translation ({modelName})",
processingTime=getattr(response, "processingTime", None),
bytesSent=getattr(response, "bytesSent", None),
bytesReceived=getattr(response, "bytesReceived", None),
)
except Exception as e:
logger.error("i18n billing callback failed: %s", e)
return _cb
async def _translateBatch(
keysToTranslate: Dict[str, str],
targetLanguageLabel: str,
targetCode: str,
billingCallback=None,
) -> Dict[str, str]:
"""Translate German keys into targetLanguageLabel.
keysToTranslate: { germanKey: uiContext }
Returns: { germanKey: translatedValue }
"""
if not keysToTranslate:
return {}
aiObjects = await _getAiObjects()
allKeys = list(keysToTranslate.items())
totalBatches = math.ceil(len(allKeys) / _TRANSLATE_BATCH_SIZE)
result: Dict[str, str] = {}
for batchIdx in range(totalBatches):
chunk = allKeys[batchIdx * _TRANSLATE_BATCH_SIZE : (batchIdx + 1) * _TRANSLATE_BATCH_SIZE]
payload = [{"key": k, "context": v} for k, v in chunk]
jsonPayload = json.dumps(payload, ensure_ascii=False)
systemPrompt = (
f"Du bist ein professioneller Übersetzer für Software-UI-Texte. "
f"Du erhältst ein JSON-Array mit Objekten: {{\"key\": \"deutscher Text\", \"context\": \"UI-Kontext\"}}. "
f"Der Kontext beschreibt, wo der Text in der Anwendung verwendet wird (Datei, Komponente). "
f"Übersetze jeden «key» ins {targetLanguageLabel} (ISO {targetCode}). "
f"Behalte Platzhalter wie {{variable}} exakt bei. "
f"Antworte NUR mit einem JSON-Objekt — Keys = deutsche Originaltexte, Values = Übersetzungen. "
f"Kein Markdown, kein Kommentar."
)
request = AiCallRequest(
prompt=f"Übersetze diese UI-Labels:\n{jsonPayload}",
context=systemPrompt,
options=AiCallOptions(
operationType=OperationTypeEnum.DATA_GENERATE,
priority=PriorityEnum.BALANCED,
compressPrompt=False,
compressContext=False,
resultFormat="json",
temperature=0.2,
),
)
if billingCallback:
aiObjects.billingCallback = billingCallback
try:
response = await aiObjects.callWithTextContext(request)
if response and response.content:
raw = response.content.strip()
if raw.startswith("```"):
raw = re.sub(r"^```[a-z]*\n?", "", raw)
raw = re.sub(r"\n?```$", "", raw)
parsed = json.loads(raw)
if isinstance(parsed, dict):
result.update(parsed)
else:
logger.warning("i18n AI batch %d/%d returned non-dict", batchIdx + 1, totalBatches)
else:
logger.warning("i18n AI batch %d/%d empty response", batchIdx + 1, totalBatches)
except json.JSONDecodeError as je:
logger.error("i18n AI batch %d/%d JSON parse error: %s", batchIdx + 1, totalBatches, je)
except Exception as e:
logger.error("i18n AI batch %d/%d failed: %s", batchIdx + 1, totalBatches, e)
finally:
aiObjects.billingCallback = None
return result
def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str:
userId = str(currentUser.id)
memberIds = _userMemberMandateIds(currentUser)
if not memberIds:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Mindestens eine Mandats-Mitgliedschaft ist für die AI-Nutzung erforderlich.",
)
headerRaw = (
request.headers.get("X-Mandate-Id") or request.headers.get("x-mandate-id") or ""
).strip()
if headerRaw:
if headerRaw not in memberIds:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="X-Mandate-Id ist kein Mandat Ihrer Mitgliedschaft.",
)
if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId):
return headerRaw
for mid in memberIds:
if _mandatePassesAiPoolBilling(currentUser, mid, userId):
return mid
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="Nicht genügend AI-Guthaben (Mandats-Pool) für diese Aktion.",
)
# ---------------------------------------------------------------------------
# xx-Master sync from frontend codebase (local dev fallback)
# ---------------------------------------------------------------------------
_REPO_ROOT = Path(__file__).resolve().parents[3]
_FRONTEND_SRC = _REPO_ROOT / "frontend_nyla" / "src"
_T_CALL_RE = re.compile(r"""\bt\(\s*'((?:\\.|[^'])+)'\s*(?:,|\))""")
def _scanCodebaseKeys() -> List[dict]:
"""Local dev fallback: scan frontend src for t() calls. Returns entries with context='ui'."""
keys: Set[str] = set()
if not _FRONTEND_SRC.is_dir():
logger.warning("i18n codebase scan: %s not found", _FRONTEND_SRC)
return []
for ext in ("*.tsx", "*.ts"):
for filepath in _FRONTEND_SRC.rglob(ext):
try:
content = filepath.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
for m in _T_CALL_RE.finditer(content):
raw = m.group(1)
raw = raw.replace("\\'", "'")
if raw:
keys.add(raw)
return [{"context": "ui", "key": k, "value": ""} for k in sorted(keys)]
async def _readOptionalEntriesFromBody(request: Request) -> Optional[List[dict]]:
"""Read entries from request body. Accepts {entries: [{context, key, value}, ...]}."""
body = await request.body()
if not body or not body.strip():
return None
try:
data = json.loads(body.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(data, dict) or "entries" not in data:
return None
entries = data.get("entries")
if not isinstance(entries, list):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Feld «entries» muss ein JSON-Array sein.",
)
result = []
for e in entries:
if not isinstance(e, dict) or not e.get("key"):
continue
result.append({
"context": str(e.get("context", "ui")),
"key": str(e["key"]),
"value": str(e.get("value", "")),
})
return result if result else None
def _syncXxMaster(db, userId: Optional[str], incomingEntries: List[dict]) -> Dict[str, Any]:
"""Synchronise the xx base set with incoming entries (from build bundle or codebase scan).
- Keys in incoming but not in DB -> add
- Keys in DB but not in incoming -> remove
- Keys in both -> update context (value)
"""
if not incomingEntries:
logger.warning("i18n xx-sync: no entries — aborting")
return {"added": [], "removed": [], "entriesCount": 0, "error": "No entries to sync"}
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"})
if not rows:
now = getUtcTimestamp()
rec = {
"id": "xx",
"label": "Basisset (Meta)",
"entries": incomingEntries,
"status": "complete",
"isDefault": True,
"sysCreatedAt": now,
"sysCreatedBy": userId,
"sysModifiedAt": now,
"sysModifiedBy": userId,
}
db.recordCreate(UiLanguageSet, rec)
allKeys = [e["key"] for e in incomingEntries]
logger.info("i18n xx-master created: %d entries", len(incomingEntries))
return {"added": allKeys, "removed": [], "entriesCount": len(incomingEntries)}
row = dict(rows[0])
curEntries = _rowEntries(row)
curByKey = {e["key"]: e for e in curEntries}
incomingByKey = {e["key"]: e for e in incomingEntries}
incomingKeys = set(incomingByKey.keys())
dbKeys = set(curByKey.keys())
added = sorted(incomingKeys - dbKeys)
removed = sorted(dbKeys - incomingKeys)
newEntries = []
for e in incomingEntries:
newEntries.append({"context": e["context"], "key": e["key"], "value": e["value"]})
for e in curEntries:
if e["key"] not in incomingKeys:
continue
if not added and not removed and all(
curByKey.get(e["key"], {}).get("value") == e["value"]
and curByKey.get(e["key"], {}).get("context") == e["context"]
for e in incomingEntries
):
return {"added": [], "removed": [], "entriesCount": len(newEntries)}
now = getUtcTimestamp()
row["entries"] = newEntries
if "keys" in row:
del row["keys"]
row["sysModifiedAt"] = now
row["sysModifiedBy"] = userId
db.recordModify(UiLanguageSet, "xx", row)
logger.info("i18n xx-master sync: +%d added, -%d removed, total=%d", len(added), len(removed), len(newEntries))
return {"added": added, "removed": removed, "entriesCount": len(newEntries)}
# --- Public -----------------------------------------------------------------
@router.get("/codes")
async def list_language_codes():
db = _publicMgmtDb()
rows = db.getRecordset(UiLanguageSet)
out = []
for r in rows:
entries = _rowEntries(r)
out.append(
{
"code": r["id"],
"label": r.get("label"),
"status": r.get("status"),
"isDefault": bool(r.get("isDefault")),
"entriesCount": len(entries),
}
)
return sorted(out, key=lambda x: (not x.get("isDefault"), x["code"]))
@router.get("/sets/{code}")
async def get_language_set(code: str):
db = _publicMgmtDb()
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if not rows:
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
return _row_to_public(rows[0])
# --- Auth user --------------------------------------------------------------
class CreateLanguageBody(BaseModel):
code: str = Field(..., min_length=2, max_length=10)
label: Optional[str] = Field(default=None, max_length=80)
def _validate_iso2_code(code: str) -> str:
c = code.strip().lower()
if not re.fullmatch(r"[a-z]{2}", c):
raise HTTPException(
status_code=400, detail="Nur ISO-639-1 Zwei-Buchstaben-Codes erlaubt."
)
return c
def _run_create_language_job(userId: str, code: str, label: str, currentUser: User, mandateId: str) -> None:
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(_run_create_language_job_async(userId, code, label, currentUser, mandateId))
finally:
loop.close()
async def _run_create_language_job_async(userId: str, code: str, label: str, currentUser: User, mandateId: str) -> None:
try:
db = _publicMgmtDb()
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if not rows:
return
xxEntries = _loadMasterXxEntries(db)
if not xxEntries:
logger.error("i18n create job: no xx master entries found")
return
toTranslate = {e["key"]: e.get("value", "") for e in xxEntries}
billingCb = _makeBillingCallback(currentUser, mandateId)
translated = await _translateBatch(toTranslate, label, code, billingCallback=billingCb)
finalEntries = []
for e in xxEntries:
k = e["key"]
finalEntries.append({
"context": e["context"],
"key": k,
"value": translated.get(k, f"[{k}]"),
})
missingCount = sum(1 for e in xxEntries if e["key"] not in translated)
finalStatus = "complete" if missingCount == 0 else "incomplete"
now = getUtcTimestamp()
merged = dict(rows[0])
merged["entries"] = finalEntries
if "keys" in merged:
del merged["keys"]
merged["status"] = finalStatus
merged["label"] = label
merged["sysModifiedAt"] = now
merged["sysModifiedBy"] = userId
db.recordModify(UiLanguageSet, code, merged)
statusHint = "" if finalStatus == "complete" else f" ({missingCount} Keys ohne Übersetzung)"
_createNotification(
userId,
NotificationType.SYSTEM,
title="Sprachset erstellt",
message=f"Die Sprache «{label}» ({code}) wurde per KI übersetzt{statusHint}.",
)
logger.info("i18n create job done: code=%s, translated=%d/%d", code, len(translated), len(xxEntries))
except Exception as e:
logger.exception("create language job failed: %s", e)
_createNotification(
userId,
NotificationType.SYSTEM,
title="Sprachset fehlgeschlagen",
message=f"Fehler bei «{code}»: {e}",
)
@router.post("/sets")
async def create_language_set(
request: Request,
body: CreateLanguageBody,
background: BackgroundTasks,
currentUser: User = Depends(getCurrentUser),
):
mandateId = _resolveMandateIdForAiI18n(request, currentUser)
code = _validate_iso2_code(body.code)
if code == "xx":
raise HTTPException(status_code=400, detail="Das Basisset «xx» kann nicht manuell angelegt werden.")
db = _publicMgmtDb()
existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if existing:
raise HTTPException(status_code=409, detail="Dieses Sprachset existiert bereits.")
xxEntries = _loadMasterXxEntries(db)
if not xxEntries:
raise HTTPException(status_code=503, detail="Basisset (xx) nicht vorhanden. Bitte zuerst UI-Keys einlesen.")
resolvedLabel = (body.label or "").strip() if body.label else ""
if not resolvedLabel:
resolvedLabel = _ISO_LABELS.get(code, code)
now = getUtcTimestamp()
uid = str(currentUser.id)
rec: dict = {
"id": code,
"label": resolvedLabel,
"entries": [],
"status": "generating",
"isDefault": False,
"sysCreatedAt": now,
"sysCreatedBy": uid,
"sysModifiedAt": now,
"sysModifiedBy": uid,
}
db.recordCreate(UiLanguageSet, rec)
background.add_task(_run_create_language_job, uid, code, resolvedLabel, currentUser, mandateId)
_createNotification(
uid,
NotificationType.SYSTEM,
title="Sprachset wird erzeugt",
message=f"Die Sprache «{code}» wird im Hintergrund per KI übersetzt.",
)
return {"status": "accepted", "code": code}
def _compute_language_sync_diff(db, code: str) -> dict:
"""Return key sync metrics before AI translate (no DB writes)."""
if code == "xx":
raise HTTPException(status_code=400, detail="Das xx-Set wird separat synchronisiert.")
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if not rows:
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
xx_entries = _loadMasterXxEntries(db)
if not xx_entries:
raise HTTPException(status_code=503, detail="Basisset (xx) nicht vorhanden.")
row = dict(rows[0])
cur_entries = _rowEntries(row)
cur_by_key = {e["key"]: e for e in cur_entries}
xx_by_key = {e["key"]: e for e in xx_entries}
master_keys = set(xx_by_key.keys())
current_keys = set(cur_by_key.keys())
added_count = len(master_keys - current_keys)
removed_count = len(current_keys - master_keys)
return {
"code": code,
"addedCount": added_count,
"removedCount": removed_count,
"masterEntryCount": len(master_keys),
"currentEntryCount": len(current_keys),
}
async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: Optional[User] = None) -> dict:
"""Synchronise a language set (incl. de) against the xx base set via AI."""
if code == "xx":
raise HTTPException(status_code=400, detail="Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.")
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if not rows:
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
xxEntries = _loadMasterXxEntries(db)
if not xxEntries:
raise HTTPException(status_code=503, detail="Basisset (xx) nicht vorhanden.")
row = dict(rows[0])
curEntries = _rowEntries(row)
curByKey = {e["key"]: e for e in curEntries}
xxByKey = {e["key"]: e for e in xxEntries}
masterKeys = set(xxByKey.keys())
currentKeys = set(curByKey.keys())
removedKeys = sorted(currentKeys - masterKeys)
addedKeys = sorted(masterKeys - currentKeys)
translatedCount = 0
if addedKeys:
toTranslate = {k: xxByKey[k].get("value", "") for k in addedKeys}
langLabel = row.get("label") or code
billingCb = None
if adminUser:
memberIds = _userMemberMandateIds(adminUser)
if memberIds:
billingCb = _makeBillingCallback(adminUser, memberIds[0])
try:
translated = await _translateBatch(toTranslate, langLabel, code, billingCallback=billingCb)
translatedCount = sum(1 for k in addedKeys if k in translated)
except Exception as e:
logger.error("AI translation during sync failed for %s: %s", code, e)
translated = {}
for k in addedKeys:
curByKey[k] = {
"context": xxByKey[k]["context"],
"key": k,
"value": translated.get(k, f"[{k}]"),
}
for k in removedKeys:
del curByKey[k]
for k in masterKeys & currentKeys:
curByKey[k]["context"] = xxByKey[k]["context"]
newEntries = [curByKey[k] for k in sorted(curByKey.keys(), key=lambda x: x.lower())]
now = getUtcTimestamp()
untranslated = len(addedKeys) - translatedCount
row["entries"] = newEntries
if "keys" in row:
del row["keys"]
row["status"] = "complete" if untranslated == 0 else "incomplete"
row["sysModifiedAt"] = now
row["sysModifiedBy"] = userId
db.recordModify(UiLanguageSet, code, row)
return {
"code": code,
"added": addedKeys,
"removed": removedKeys,
"translated": translatedCount,
"entriesCount": len(newEntries),
}
@router.put("/sets/sync-xx")
async def sync_xx_master(
request: Request,
adminUser: User = Depends(requireSysAdminRole),
):
"""Synchronise the xx base set from the frontend build artefact.
Expects JSON body: {"entries": [{"context":"ui","key":"","value":""}, …]}
Falls back to local codebase scan if no body provided (dev mode).
"""
db = getMgmtInterface(adminUser, mandateId=None).db
fromBody = await _readOptionalEntriesFromBody(request)
entries = fromBody if fromBody is not None else _scanCodebaseKeys()
return _syncXxMaster(db, str(adminUser.id), entries)
@router.put("/sets/update-all")
async def update_all_language_sets(
request: Request,
adminUser: User = Depends(requireSysAdminRole),
):
"""Sync xx-master (if body provided), then update ALL language sets via AI."""
db = getMgmtInterface(adminUser, mandateId=None).db
fromBody = await _readOptionalEntriesFromBody(request)
xxSync: Optional[dict] = None
if fromBody is not None:
xxSync = _syncXxMaster(db, str(adminUser.id), fromBody)
if xxSync.get("error"):
return {"xxSync": xxSync, "updated": []}
rows = db.getRecordset(UiLanguageSet)
results = []
for r in rows:
cid = r["id"]
if cid == "xx":
continue
res = await _syncLanguageWithXx(db, cid, str(adminUser.id), adminUser=adminUser)
results.append(res)
return {"xxSync": xxSync, "updated": results}
@router.get("/sets/{code}/sync-diff")
async def get_language_sync_diff(
code: str,
adminUser: User = Depends(requireSysAdminRole),
):
"""How many keys would be added/removed vs xx before running a full sync (SysAdmin)."""
c = code.strip().lower()
if c in ("update-all", "sync-xx", "sync-de"):
raise HTTPException(status_code=400, detail="Ungültiger Sprachcode.")
db = getMgmtInterface(adminUser, mandateId=None).db
return _compute_language_sync_diff(db, c)
@router.put("/sets/{code}")
async def update_language_set(
code: str,
adminUser: User = Depends(requireSysAdminRole),
):
c = code.strip().lower()
if c in ("update-all", "sync-xx", "sync-de"):
raise HTTPException(status_code=400, detail="Ungültiger Sprachcode.")
if c == "xx":
raise HTTPException(status_code=400, detail="Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.")
db = getMgmtInterface(adminUser, mandateId=None).db
return await _syncLanguageWithXx(db, c, str(adminUser.id), adminUser=adminUser)
@router.delete("/sets/{code}")
async def delete_language_set(
code: str,
adminUser: User = Depends(requireSysAdminRole),
):
c = code.strip().lower()
if c in _PROTECTED_CODES:
raise HTTPException(status_code=400, detail=f"Das Set «{c}» darf nicht gelöscht werden.")
db = getMgmtInterface(adminUser, mandateId=None).db
ok = db.recordDelete(UiLanguageSet, c)
if not ok:
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
return {"deleted": c}
@router.get("/sets/{code}/download", dependencies=[Depends(getCurrentUser)])
async def download_language_set(
code: str,
currentUser: User = Depends(getCurrentUser),
):
db = _publicMgmtDb()
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code.strip().lower()})
if not rows:
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
payload = _row_to_public(rows[0])
raw = json.dumps(payload, ensure_ascii=False, indent=2)
return Response(
content=raw,
media_type="application/json",
headers={
"Content-Disposition": f'attachment; filename="ui-language-{code}.json"'
},
)
# --- Export / Import (full DB) -----------------------------------------------
@router.get("/export")
async def export_all_language_sets(
adminUser: User = Depends(requireSysAdminRole),
):
db = getMgmtInterface(adminUser, mandateId=None).db
rows = db.getRecordset(UiLanguageSet)
payload = []
for r in rows:
entries = _rowEntries(r)
payload.append({
"id": r["id"],
"label": r.get("label", ""),
"entries": entries,
"status": r.get("status", "complete"),
"isDefault": bool(r.get("isDefault", False)),
})
payload.sort(key=lambda x: (not x.get("isDefault"), x["id"]))
raw = json.dumps(payload, ensure_ascii=False, indent=2)
return Response(
content=raw,
media_type="application/json",
headers={
"Content-Disposition": 'attachment; filename="ui-languages-export.json"'
},
)
@router.post("/import")
async def import_language_sets(
file: UploadFile = File(...),
adminUser: User = Depends(requireSysAdminRole),
):
if not file.filename or not file.filename.endswith(".json"):
raise HTTPException(status_code=400, detail="Nur .json-Dateien erlaubt.")
try:
raw = await file.read()
data = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise HTTPException(status_code=400, detail=f"Ungültiges JSON: {e}")
if not isinstance(data, list):
raise HTTPException(status_code=400, detail="JSON muss ein Array von Sprachsets sein.")
db = getMgmtInterface(adminUser, mandateId=None).db
now = getUtcTimestamp()
uid = str(adminUser.id)
created = []
updated = []
for entry in data:
if not isinstance(entry, dict):
continue
code = str(entry.get("id", "")).strip().lower()
if not code or len(code) < 2:
continue
entries = entry.get("entries")
if not isinstance(entries, list):
keys = entry.get("keys")
if isinstance(keys, dict):
entries = [{"context": "ui", "key": k, "value": v} for k, v in keys.items()]
else:
continue
label = str(entry.get("label", code))
entryStatus = str(entry.get("status", "complete"))
isDefault = bool(entry.get("isDefault", False))
existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if existing:
row = dict(existing[0])
row["entries"] = entries
if "keys" in row:
del row["keys"]
row["label"] = label
row["status"] = entryStatus
row["isDefault"] = isDefault
row["sysModifiedAt"] = now
row["sysModifiedBy"] = uid
db.recordModify(UiLanguageSet, code, row)
updated.append(code)
else:
rec = {
"id": code,
"label": label,
"entries": entries,
"status": entryStatus,
"isDefault": isDefault,
"sysCreatedAt": now,
"sysCreatedBy": uid,
"sysModifiedAt": now,
"sysModifiedBy": uid,
}
db.recordCreate(UiLanguageSet, rec)
created.append(code)
logger.info("i18n import: created=%s, updated=%s", created, updated)
return {"created": created, "updated": updated, "totalProcessed": len(created) + len(updated)}