feat: Dynamisches Sprachpaket-System für die Bedienoberfläche
Teil 1 des "mehrsprachig variabel"-Umbaus (Speiseplan-Inhalte folgen
in einer späteren Version). Sprachen sind jetzt zur Laufzeit über eine
neue Admin-Seite "Sprachen" verwaltbar statt fest im Code zu stehen:
- Neue Tabellen languages/ui_translations (Migration 0004), bestehende
Englisch-/Polnisch-Texte per generiertem Seed automatisch übernommen
(Migration 0005) - keine manuelle Nacharbeit, nichts geht verloren.
- lib/i18n/flatten.ts: verschachtelte Dict-Objekte (inkl. Arrays wie
weekdays.long) <-> flache Schlüssel-Wert-Paare, Basis für Vorlage-
Export/-Import.
- lib/i18n/template.ts: Vollständigkeits-/Veraltet-Berechnung (Hash
über die DE-Schlüsselliste) und Import-Validierung (Platzhalter wie
{message} müssen erhalten bleiben, unbekannte Schlüssel werden
ignoriert statt Fehler zu werfen).
- getDictionary()/getLocale() (lib/i18n/index.ts) jetzt asynchron und
DB-gestützt für Nicht-Basis-Sprachen, mit Fallback auf Deutsch für
fehlende Schlüssel. Locale-Typ bewusst von festem TypeScript-Tupel
auf string generalisiert (Kompromiss: Compiler prüft Vollständigkeit
nicht mehr automatisch, das übernimmt jetzt die Veraltet-Erkennung
zur Laufzeit).
- LanguageSwitcher/LocaleProvider lesen aktive Sprachen jetzt aus dem
Context statt aus einer festen Konstante; blendet sich bei nur einer
aktiven Sprache automatisch aus.
- Neue Admin-Routen app/api/admin/languages/* (Liste inkl.
Vollständigkeit, Anlegen, Aktivieren/Deaktivieren mit
"mindestens eine muss aktiv bleiben"-Schutz, Löschen außer
Basis-Sprache, Vorlage-Download, Datei-Import) + LanguageManager.tsx.
Vollständig gegen echten DB-Zustand getestet: Vorlage-Export/-Import
inkl. Platzhalter-Validierung, neue Sprache anlegen, auf eine Sprache
reduzieren (Umschalter verschwindet, "letzte Sprache"-Schutz greift),
Veraltet-Erkennung nach simuliertem neuen Schlüssel, Cascade-Delete.
Version 1.10.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { dictionaries } from "./dictionaries";
|
||||
import { flattenDict } from "./flatten";
|
||||
|
||||
/** Aktuelle deutsche Vorlage als flache Schlüssel-Wert-Paare – Basis
|
||||
* für Export (Vorlage für Übersetzer), Vollständigkeits- und
|
||||
* Veraltet-Prüfung. */
|
||||
export function currentTemplate(): Record<string, string> {
|
||||
return flattenDict(dictionaries.de);
|
||||
}
|
||||
|
||||
/** Hash über die (sortierte) Schlüsselliste der aktuellen Vorlage –
|
||||
* ändert sich nur, wenn Schlüssel hinzukommen/wegfallen, nicht bei
|
||||
* reinen Text-/Tippfehler-Korrekturen am deutschen Text. */
|
||||
export function currentTemplateHash(): string {
|
||||
const keys = Object.keys(currentTemplate()).sort();
|
||||
return createHash("sha256").update(keys.join("\n")).digest("hex");
|
||||
}
|
||||
|
||||
export interface LanguageCompleteness {
|
||||
total: number;
|
||||
translated: number;
|
||||
percent: number;
|
||||
missingKeys: string[];
|
||||
}
|
||||
|
||||
export function computeCompleteness(
|
||||
translated: Record<string, string>
|
||||
): LanguageCompleteness {
|
||||
const templateKeys = Object.keys(currentTemplate());
|
||||
const missingKeys = templateKeys.filter((k) => !(k in translated));
|
||||
const translatedCount = templateKeys.length - missingKeys.length;
|
||||
return {
|
||||
total: templateKeys.length,
|
||||
translated: translatedCount,
|
||||
percent:
|
||||
templateKeys.length === 0
|
||||
? 100
|
||||
: Math.round((translatedCount / templateKeys.length) * 100),
|
||||
missingKeys,
|
||||
};
|
||||
}
|
||||
|
||||
function extractPlaceholders(text: string): Set<string> {
|
||||
return new Set([...text.matchAll(/\{(\w+)\}/g)].map((m) => m[1]));
|
||||
}
|
||||
|
||||
export interface ImportValidationResult {
|
||||
entries: { key: string; value: string }[];
|
||||
skipped: { key: string; reason: string }[];
|
||||
}
|
||||
|
||||
/** Prüft eine hochgeladene Übersetzung gegen die aktuelle Vorlage:
|
||||
* unbekannte Schlüssel werden stillschweigend ignoriert (z. B. von
|
||||
* einer älteren Vorlagen-Version), Zeilen mit fehlenden Platzhaltern
|
||||
* wie {message} übersprungen statt kaputte Texte zu übernehmen. */
|
||||
export function validateImport(
|
||||
uploaded: Record<string, unknown>
|
||||
): ImportValidationResult {
|
||||
const template = currentTemplate();
|
||||
const entries: { key: string; value: string }[] = [];
|
||||
const skipped: { key: string; reason: string }[] = [];
|
||||
|
||||
for (const [key, rawValue] of Object.entries(uploaded)) {
|
||||
const templateValue = template[key];
|
||||
if (templateValue === undefined) continue;
|
||||
if (typeof rawValue !== "string" || !rawValue.trim()) {
|
||||
skipped.push({ key, reason: "empty" });
|
||||
continue;
|
||||
}
|
||||
const expected = extractPlaceholders(templateValue);
|
||||
const actual = extractPlaceholders(rawValue);
|
||||
const missing = [...expected].filter((p) => !actual.has(p));
|
||||
if (missing.length > 0) {
|
||||
skipped.push({ key, reason: `missing_placeholder:${missing[0]}` });
|
||||
continue;
|
||||
}
|
||||
entries.push({ key, value: rawValue });
|
||||
}
|
||||
|
||||
return { entries, skipped };
|
||||
}
|
||||
Reference in New Issue
Block a user