German is source of truth; EN/ES fully translated with no German residue. Auto-detects browser language (fallback German), persists choice, language switcher on all pages, localized formats/Clerk/legal. Scans store their language. Backend (T001-T003): language column on scans, openapi+codegen, ruleCatalogI18n, language threaded scans route -> analyzeSkill -> runStaticRule -> AI calls. Route/AI error messages localized via expanded i18n MESSAGES + reqLang(req) (?lang query -> Accept-Language header -> "de"). No German left in routes. Frontend (T004-T005): react-i18next framework, LanguageSwitcher, locale-aware format.ts, Clerk localizations. All page/component strings externalized to de/en/es locale area files across catalog, education, scan form/report/compare, history, dashboard, admin, legal pages. T006 verification + review-fix follow-up (this session): - Applied formatNumber to all visible metrics in scan-report (risk score, severity counts, security/privacy) and scan-compare (risk score, file count, diff counts); PDF/HTML export numbers formatted via Intl.NumberFormat(lng). - Fixed leftover `@workspace/n` import alias in i18n/index.ts -> real package `@workspace/api-client-react` (was failing workspace typecheck). - Verified: full `pnpm run typecheck` green; api-server tests 72/72 pass; curl confirms localized error responses (de/en/es) on scans route. Deviations: AI connection-test prompts left in German intentionally (sent to the model, not user-facing). proposeFollowUpTasks already created #52. Replit-Task-Id: 9f137230-db11-45dc-9276-4e5cbcceff03
220 lines
7.2 KiB
TypeScript
220 lines
7.2 KiB
TypeScript
import type { Request } from "express";
|
||
import type { Lang } from "./ruleCatalogI18n";
|
||
|
||
import { normalizeLang } from "./ruleCatalogI18n";
|
||
|
||
export type { Lang } from "./ruleCatalogI18n";
|
||
export { normalizeLang, SUPPORTED_LANGS } from "./ruleCatalogI18n";
|
||
|
||
type MessageKey =
|
||
| "aiRulesDisabled"
|
||
| "aiNoProvider"
|
||
| "aiNoToken"
|
||
| "aiUnknownError"
|
||
| "invalidId"
|
||
| "invalidInput"
|
||
| "scanNotFound"
|
||
| "ruleNotFound"
|
||
| "promptNotFound"
|
||
| "providerNotFound"
|
||
| "rateLimited"
|
||
| "zipMissing"
|
||
| "fileMissing"
|
||
| "textMissing"
|
||
| "noAnalyzableFiles"
|
||
| "skillUnreadable"
|
||
| "analysisFailed"
|
||
| "noDownloadableFiles"
|
||
| "onlyPassedDownloadable"
|
||
| "descriptionFailed"
|
||
| "noApiTokenPlain"
|
||
| "noApiTokenProvided"
|
||
| "modelsLoadFailed"
|
||
| "connSuccessReply"
|
||
| "connSuccessModels"
|
||
| "connSuccessNoModels"
|
||
| "connReplyEmpty"
|
||
| "connFailed";
|
||
|
||
const MESSAGES: Record<MessageKey, Record<Lang, string>> = {
|
||
aiRulesDisabled: {
|
||
de: "KI-Regeln sind im Regelwerk deaktiviert.",
|
||
en: "AI rules are disabled in the rule set.",
|
||
es: "Las reglas de IA están desactivadas en el conjunto de reglas.",
|
||
},
|
||
aiNoProvider: {
|
||
de: "Kein aktiver KI-Provider konfiguriert. Bitte im Admin-Bereich einrichten.",
|
||
en: "No active AI provider configured. Please set one up in the admin area.",
|
||
es: "No hay ningún proveedor de IA activo configurado. Configúrelo en el área de administración.",
|
||
},
|
||
aiNoToken: {
|
||
de: 'Für den Provider "{name}" ist kein API-Token hinterlegt.',
|
||
en: 'No API token is stored for the provider "{name}".',
|
||
es: 'No hay ningún token de API almacenado para el proveedor "{name}".',
|
||
},
|
||
aiUnknownError: {
|
||
de: "Unbekannter KI-Fehler",
|
||
en: "Unknown AI error",
|
||
es: "Error de IA desconocido",
|
||
},
|
||
invalidId: {
|
||
de: "Ungültige ID",
|
||
en: "Invalid ID",
|
||
es: "ID no válido",
|
||
},
|
||
invalidInput: {
|
||
de: "Ungültige Eingabe",
|
||
en: "Invalid input",
|
||
es: "Entrada no válida",
|
||
},
|
||
scanNotFound: {
|
||
de: "Scan nicht gefunden",
|
||
en: "Scan not found",
|
||
es: "Análisis no encontrado",
|
||
},
|
||
ruleNotFound: {
|
||
de: "Regel nicht gefunden",
|
||
en: "Rule not found",
|
||
es: "Regla no encontrada",
|
||
},
|
||
promptNotFound: {
|
||
de: "Prompt nicht gefunden",
|
||
en: "Prompt not found",
|
||
es: "Prompt no encontrado",
|
||
},
|
||
providerNotFound: {
|
||
de: "Provider nicht gefunden",
|
||
en: "Provider not found",
|
||
es: "Proveedor no encontrado",
|
||
},
|
||
rateLimited: {
|
||
de: "Zu viele Scans in kurzer Zeit. Bitte später erneut versuchen.",
|
||
en: "Too many scans in a short time. Please try again later.",
|
||
es: "Demasiados análisis en poco tiempo. Inténtelo de nuevo más tarde.",
|
||
},
|
||
zipMissing: {
|
||
de: "ZIP-Inhalt fehlt.",
|
||
en: "ZIP content is missing.",
|
||
es: "Falta el contenido del ZIP.",
|
||
},
|
||
fileMissing: {
|
||
de: "Dateiinhalt fehlt.",
|
||
en: "File content is missing.",
|
||
es: "Falta el contenido del archivo.",
|
||
},
|
||
textMissing: {
|
||
de: "Text fehlt.",
|
||
en: "Text is missing.",
|
||
es: "Falta el texto.",
|
||
},
|
||
noAnalyzableFiles: {
|
||
de: "Keine analysierbaren Dateien gefunden.",
|
||
en: "No analyzable files found.",
|
||
es: "No se encontraron archivos analizables.",
|
||
},
|
||
skillUnreadable: {
|
||
de: "Das Skill konnte nicht gelesen werden. Bitte prüfen Sie das Format (gültiges ZIP / Textdatei).",
|
||
en: "The skill could not be read. Please check the format (valid ZIP / text file).",
|
||
es: "No se pudo leer la skill. Compruebe el formato (ZIP válido / archivo de texto).",
|
||
},
|
||
analysisFailed: {
|
||
de: "Die Analyse ist fehlgeschlagen.",
|
||
en: "The analysis failed.",
|
||
es: "El análisis falló.",
|
||
},
|
||
noDownloadableFiles: {
|
||
de: "Für dieses Skill sind keine herunterladbaren Dateien gespeichert.",
|
||
en: "No downloadable files are stored for this skill.",
|
||
es: "No hay archivos descargables almacenados para esta skill.",
|
||
},
|
||
onlyPassedDownloadable: {
|
||
de: "Nur Skills mit dem Ergebnis „Bestanden“ können heruntergeladen werden.",
|
||
en: "Only skills with a “Passed” result can be downloaded.",
|
||
es: "Solo se pueden descargar las skills con resultado «Aprobado».",
|
||
},
|
||
descriptionFailed: {
|
||
de: "Die Beschreibung konnte nicht erzeugt werden. Bitte Provider-Konfiguration und KI-Prompts prüfen.",
|
||
en: "The description could not be generated. Please check the provider configuration and AI prompts.",
|
||
es: "No se pudo generar la descripción. Compruebe la configuración del proveedor y los prompts de IA.",
|
||
},
|
||
noApiTokenPlain: {
|
||
de: "Kein API-Token hinterlegt.",
|
||
en: "No API token stored.",
|
||
es: "No hay ningún token de API almacenado.",
|
||
},
|
||
noApiTokenProvided: {
|
||
de: "Kein API-Token angegeben.",
|
||
en: "No API token provided.",
|
||
es: "No se proporcionó ningún token de API.",
|
||
},
|
||
modelsLoadFailed: {
|
||
de: "Modelle konnten nicht geladen werden.",
|
||
en: "Models could not be loaded.",
|
||
es: "No se pudieron cargar los modelos.",
|
||
},
|
||
connSuccessReply: {
|
||
de: "Verbindung erfolgreich. Antwort: {reply}",
|
||
en: "Connection successful. Reply: {reply}",
|
||
es: "Conexión correcta. Respuesta: {reply}",
|
||
},
|
||
connSuccessModels: {
|
||
de: "Verbindung erfolgreich. {count} Modelle verfügbar.",
|
||
en: "Connection successful. {count} models available.",
|
||
es: "Conexión correcta. {count} modelos disponibles.",
|
||
},
|
||
connSuccessNoModels: {
|
||
de: "Verbindung erfolgreich. Es wurden keine Modelle gefunden – bitte das Modell manuell eingeben.",
|
||
en: "Connection successful. No models were found – please enter the model manually.",
|
||
es: "Conexión correcta. No se encontraron modelos: introduzca el modelo manualmente.",
|
||
},
|
||
connReplyEmpty: {
|
||
de: "(leer)",
|
||
en: "(empty)",
|
||
es: "(vacío)",
|
||
},
|
||
connFailed: {
|
||
de: "Verbindung fehlgeschlagen.",
|
||
en: "Connection failed.",
|
||
es: "La conexión falló.",
|
||
},
|
||
};
|
||
|
||
export function t(
|
||
key: MessageKey,
|
||
lang: Lang,
|
||
vars?: Record<string, string>,
|
||
): string {
|
||
let msg = MESSAGES[key][lang];
|
||
if (vars) {
|
||
for (const [k, v] of Object.entries(vars)) {
|
||
msg = msg.replace(`{${k}}`, v);
|
||
}
|
||
}
|
||
return msg;
|
||
}
|
||
|
||
// Phrases injected into AI prompts so the model produces output in the
|
||
// requested language. The directive overrides any language line baked into the
|
||
// stored prompts (which historically said "Antworte auf Deutsch.").
|
||
const LANGUAGE_DIRECTIVE: Record<Lang, string> = {
|
||
de: "WICHTIG: Verfasse alle Ausgabetexte (Beschreibung, Findings, Empfehlungen) ausschließlich auf Deutsch.",
|
||
en: "IMPORTANT: Write all output text (description, findings, remediation) exclusively in English.",
|
||
es: "IMPORTANTE: Redacta todos los textos de salida (descripción, hallazgos, recomendaciones) exclusivamente en español.",
|
||
};
|
||
|
||
export function languageDirective(lang: Lang): string {
|
||
return LANGUAGE_DIRECTIVE[lang];
|
||
}
|
||
|
||
// Resolve the language for a request's user-facing error messages. Prefers an
|
||
// explicit `?lang=` query param, then the `Accept-Language` header (the web
|
||
// client sends the active UI language), defaulting to German.
|
||
export function reqLang(req: Request): Lang {
|
||
const q = req.query?.lang;
|
||
if (typeof q === "string" && q) return normalizeLang(q);
|
||
const header = req.headers["accept-language"];
|
||
if (typeof header === "string" && header) {
|
||
return normalizeLang(header.split(",")[0].trim().slice(0, 2));
|
||
}
|
||
return "de";
|
||
}
|