feat(i18n): add German translations and key parity test
- Create all 6 German namespace JSON files (common, collection, threads, setups, onboarding, settings) - Register German locale in i18n configuration with supportedLngs - Add key parity test ensuring en/de have identical key structures - All 19 locale parity tests pass, all 15 formatter tests pass Phase 34, Plan 05
This commit is contained in:
@@ -9,6 +9,13 @@ import enSettings from "../locales/en/settings.json";
|
||||
import enSetups from "../locales/en/setups.json";
|
||||
import enThreads from "../locales/en/threads.json";
|
||||
|
||||
import deCollection from "../locales/de/collection.json";
|
||||
import deCommon from "../locales/de/common.json";
|
||||
import deOnboarding from "../locales/de/onboarding.json";
|
||||
import deSettings from "../locales/de/settings.json";
|
||||
import deSetups from "../locales/de/setups.json";
|
||||
import deThreads from "../locales/de/threads.json";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
@@ -22,6 +29,14 @@ i18n
|
||||
onboarding: enOnboarding,
|
||||
settings: enSettings,
|
||||
},
|
||||
de: {
|
||||
common: deCommon,
|
||||
collection: deCollection,
|
||||
threads: deThreads,
|
||||
setups: deSetups,
|
||||
onboarding: deOnboarding,
|
||||
settings: deSettings,
|
||||
},
|
||||
},
|
||||
supportedLngs: ["en", "de"],
|
||||
fallbackLng: "en",
|
||||
|
||||
31
src/client/locales/de/collection.json
Normal file
31
src/client/locales/de/collection.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"title": "Sammlung",
|
||||
"gear": "Ausruestung",
|
||||
"planning": "Planung",
|
||||
"empty": {
|
||||
"title": "Ihre Sammlung ist leer",
|
||||
"description": "Beginnen Sie mit der Katalogisierung Ihrer Ausruestung, indem Sie Ihren ersten Gegenstand hinzufuegen. Verfolgen Sie Gewicht, Preis und organisieren Sie nach Kategorie.",
|
||||
"addFirst": "Ersten Gegenstand hinzufuegen"
|
||||
},
|
||||
"form": {
|
||||
"name": "Name",
|
||||
"nameRequired": "Name *",
|
||||
"namePlaceholder": "z.B. Osprey Talon 22",
|
||||
"weight": "Gewicht (g)",
|
||||
"weightPlaceholder": "z.B. 680",
|
||||
"price": "Preis ($)",
|
||||
"pricePlaceholder": "z.B. 129,99",
|
||||
"quantity": "Menge",
|
||||
"category": "Kategorie",
|
||||
"notes": "Notizen",
|
||||
"notesPlaceholder": "Zusaetzliche Notizen...",
|
||||
"productLink": "Produktlink",
|
||||
"urlPlaceholder": "https://..."
|
||||
},
|
||||
"classification": {
|
||||
"ultralight": "Ultraleicht",
|
||||
"light": "Leicht",
|
||||
"medium": "Mittel",
|
||||
"heavy": "Schwer"
|
||||
}
|
||||
}
|
||||
80
src/client/locales/de/common.json
Normal file
80
src/client/locales/de/common.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "Startseite",
|
||||
"collection": "Sammlung",
|
||||
"setups": "Setups",
|
||||
"discover": "Entdecken",
|
||||
"settings": "Einstellungen",
|
||||
"search": "Suchen",
|
||||
"searchPlaceholder": "Katalog durchsuchen...",
|
||||
"profile": "Profil"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Loeschen",
|
||||
"edit": "Bearbeiten",
|
||||
"create": "Erstellen",
|
||||
"close": "Schliessen",
|
||||
"back": "Zurueck",
|
||||
"confirm": "Bestaetigen",
|
||||
"continue": "Weiter",
|
||||
"tryAgain": "Erneut versuchen",
|
||||
"dismiss": "Schliessen",
|
||||
"saving": "Wird gespeichert...",
|
||||
"deleting": "Wird geloescht...",
|
||||
"creating": "Wird erstellt...",
|
||||
"loading": "Laden...",
|
||||
"addItem": "Gegenstand hinzufuegen",
|
||||
"saveChanges": "Aenderungen speichern",
|
||||
"revoke": "Widerrufen",
|
||||
"skipStep": "Diesen Schritt ueberspringen"
|
||||
},
|
||||
"errors": {
|
||||
"somethingWentWrong": "Etwas ist schiefgelaufen",
|
||||
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten",
|
||||
"nameRequired": "Name ist erforderlich",
|
||||
"positiveNumber": "Muss eine positive Zahl sein",
|
||||
"validUrl": "Muss eine gueltige URL sein (https://...)"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Anmelden",
|
||||
"signOut": "Abmelden",
|
||||
"joinGearBox": "GearBox beitreten",
|
||||
"signInToGearBox": "Bei GearBox anmelden",
|
||||
"signInDescription": "Melden Sie sich an oder erstellen Sie ein Konto, um Ihre Sammlung zu verwalten.",
|
||||
"createAccount": "Konto erstellen",
|
||||
"redirectDescription": "Sie werden zur Anmeldung weitergeleitet."
|
||||
},
|
||||
"confirm": {
|
||||
"deleteItem": "Gegenstand loeschen",
|
||||
"deleteItemMessage": "Sind Sie sicher, dass Sie <bold>{{name}}</bold> loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.",
|
||||
"deleteCandidate": "Kandidat loeschen",
|
||||
"deleteCandidateMessage": "Sind Sie sicher, dass Sie <bold>{{name}}</bold> loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.",
|
||||
"pickWinner": "Gewinner waehlen",
|
||||
"pickWinnerMessage": "<bold>{{name}}</bold> als Gewinner waehlen? Der Gegenstand wird Ihrer Sammlung hinzugefuegt und der Thread archiviert."
|
||||
},
|
||||
"externalLink": {
|
||||
"title": "Sie verlassen GearBox",
|
||||
"redirectMessage": "Sie werden weitergeleitet zu:"
|
||||
},
|
||||
"fab": {
|
||||
"addToCollection": "Zur Sammlung hinzufuegen",
|
||||
"startNewThread": "Neuen Thread starten",
|
||||
"newSetup": "Neues Setup"
|
||||
},
|
||||
"empty": {
|
||||
"noResults": "Keine Ergebnisse gefunden",
|
||||
"noItems": "Keine Gegenstaende gefunden"
|
||||
},
|
||||
"stats": {
|
||||
"items": "Gegenstaende",
|
||||
"totalWeight": "Gesamtgewicht",
|
||||
"totalSpent": "Gesamtausgaben"
|
||||
},
|
||||
"filter": {
|
||||
"showing": "{{filtered}} von {{total}} Gegenstaenden",
|
||||
"searchItems": "Gegenstaende suchen...",
|
||||
"allCategories": "Alle Kategorien"
|
||||
}
|
||||
}
|
||||
34
src/client/locales/de/onboarding.json
Normal file
34
src/client/locales/de/onboarding.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"welcome": {
|
||||
"title": "Willkommen bei GearBox",
|
||||
"subtitle": "Sagen Sie uns, was Sie interessiert, und wir helfen Ihnen, Ihre Sammlung mit Ausruestung einzurichten, die wirklich genutzt wird.",
|
||||
"cta": "Los geht's"
|
||||
},
|
||||
"hobby": {
|
||||
"title": "Was interessiert Sie?",
|
||||
"subtitle": "Waehlen Sie eins oder mehrere — wir zeigen Ihnen beliebte Ausruestung fuer jedes.",
|
||||
"continue": "Weiter"
|
||||
},
|
||||
"items": {
|
||||
"title": "Beliebte Ausruestung fuer {{hobby}}",
|
||||
"titleMultiple": "Beliebte Ausruestung fuer Ihre Hobbys",
|
||||
"subtitle": "Tippen Sie auf Gegenstaende, die Sie bereits besitzen. Wir fuegen sie Ihrer Sammlung hinzu.",
|
||||
"noCatalog": "Noch keine Ausruestung katalogisiert",
|
||||
"noCatalogDescription": "Wir bauen unseren Katalog fuer dieses Hobby noch auf. Sie koennen diesen Schritt ueberspringen und spaeter manuell Ausruestung hinzufuegen.",
|
||||
"reviewCount": "{{count}} Gegenstaende pruefen",
|
||||
"reviewCount_one": "{{count}} Gegenstand pruefen"
|
||||
},
|
||||
"review": {
|
||||
"title": "Ihre Startsammlung",
|
||||
"itemsReady": "{{count}} Gegenstaende bereit zum Hinzufuegen",
|
||||
"itemsReady_one": "{{count}} Gegenstand bereit zum Hinzufuegen",
|
||||
"noItemsSelected": "Keine Gegenstaende ausgewaehlt — Sie koennen jederzeit spaeter Ausruestung aus dem Katalog hinzufuegen.",
|
||||
"addToCollection": "Zu meiner Sammlung hinzufuegen",
|
||||
"adding": "Wird hinzugefuegt..."
|
||||
},
|
||||
"done": {
|
||||
"title": "Alles bereit!",
|
||||
"subtitle": "Ihre Sammlung ist fertig. Durchstoebern Sie jederzeit den Katalog, um mehr Ausruestung zu entdecken.",
|
||||
"cta": "Jetzt entdecken"
|
||||
}
|
||||
}
|
||||
32
src/client/locales/de/settings.json
Normal file
32
src/client/locales/de/settings.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"title": "Einstellungen",
|
||||
"language": {
|
||||
"title": "Sprache",
|
||||
"description": "Aendern Sie die Anzeigesprache der App"
|
||||
},
|
||||
"weightUnit": {
|
||||
"title": "Gewichtseinheit",
|
||||
"description": "Waehlen Sie die Einheit fuer die Gewichtsanzeige in der App"
|
||||
},
|
||||
"currency": {
|
||||
"title": "Waehrung",
|
||||
"description": "Aendert das angezeigte Waehrungssymbol. Werte werden nicht umgerechnet."
|
||||
},
|
||||
"apiKeys": {
|
||||
"title": "API-Schluessel",
|
||||
"description": "API-Schluessel ermoeglichen programmatischen Zugriff auf GearBox (z.B. von Claude Desktop oder Skripten).",
|
||||
"copyWarning": "Kopieren Sie diesen Schluessel jetzt — er wird nicht erneut angezeigt:",
|
||||
"namePlaceholder": "Schluesselname (z.B. claude-desktop)"
|
||||
},
|
||||
"importExport": {
|
||||
"title": "Import / Export",
|
||||
"description": "Exportieren Sie Ihre Ausruestungssammlung als CSV-Datei oder importieren Sie Gegenstaende aus einer CSV.",
|
||||
"export": "CSV exportieren",
|
||||
"import": "CSV importieren",
|
||||
"importing": "Wird importiert...",
|
||||
"imported": "{{count}} Gegenstaende importiert.",
|
||||
"imported_one": "{{count}} Gegenstand importiert.",
|
||||
"newCategories": "Neue Kategorien: {{categories}}",
|
||||
"noItemsFound": "Keine Gegenstaende in der CSV gefunden."
|
||||
}
|
||||
}
|
||||
43
src/client/locales/de/setups.json
Normal file
43
src/client/locales/de/setups.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"title": "Setups",
|
||||
"create": "Neues Setup",
|
||||
"empty": {
|
||||
"title": "Noch keine Setups",
|
||||
"description": "Erstellen Sie ein Setup, um Ausruestung fuer bestimmte Reisen oder Aktivitaeten zu organisieren."
|
||||
},
|
||||
"card": {
|
||||
"items": "{{count}} Gegenstaende",
|
||||
"items_one": "{{count}} Gegenstand",
|
||||
"weight": "Gewicht",
|
||||
"price": "Preis"
|
||||
},
|
||||
"share": {
|
||||
"title": "Setup teilen",
|
||||
"shareLinks": "Freigabelinks",
|
||||
"createLink": "Link erstellen",
|
||||
"noLinks": "Noch keine Freigabelinks",
|
||||
"copyLink": "Link kopieren",
|
||||
"revokeLink": "Link widerrufen",
|
||||
"copied": "Kopiert!",
|
||||
"noExpiration": "Kein Ablaufdatum",
|
||||
"expired": "Abgelaufen",
|
||||
"expiresToday": "Laeuft heute ab",
|
||||
"expiresTomorrow": "Laeuft morgen ab",
|
||||
"expiresInDays": "Laeuft in {{days}} Tagen ab",
|
||||
"daysOption": "{{days}} Tage",
|
||||
"deactivateWarning": "Bei Umstellung auf Privat werden alle Freigabelinks deaktiviert. Sie koennen durch Zurueckschalten reaktiviert werden."
|
||||
},
|
||||
"visibility": {
|
||||
"private": "Privat",
|
||||
"privateDescription": "Nur Sie haben Zugriff",
|
||||
"link": "Link-Freigabe",
|
||||
"linkDescription": "Jeder mit dem Link",
|
||||
"public": "Oeffentlich",
|
||||
"publicDescription": "Sichtbar auf Ihrem Profil"
|
||||
},
|
||||
"impact": {
|
||||
"title": "Auswirkungsvorschau",
|
||||
"adding": "Hinzufuegen",
|
||||
"removing": "Entfernen"
|
||||
}
|
||||
}
|
||||
45
src/client/locales/de/threads.json
Normal file
45
src/client/locales/de/threads.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"title": "Recherche-Threads",
|
||||
"create": {
|
||||
"title": "Neuer Thread",
|
||||
"threadName": "Thread-Name",
|
||||
"namePlaceholder": "z.B. Leichter Schlafsack",
|
||||
"category": "Kategorie",
|
||||
"nameRequired": "Thread-Name ist erforderlich",
|
||||
"selectCategory": "Bitte waehlen Sie eine Kategorie",
|
||||
"createFailed": "Thread konnte nicht erstellt werden",
|
||||
"createThread": "Thread erstellen"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
"researching": "Recherche",
|
||||
"ordered": "Bestellt",
|
||||
"arrived": "Angekommen",
|
||||
"resolved": "Abgeschlossen",
|
||||
"archived": "Archiviert"
|
||||
},
|
||||
"candidate": {
|
||||
"name": "Name",
|
||||
"price": "Preis",
|
||||
"weight": "Gewicht",
|
||||
"url": "URL",
|
||||
"pros": "Vorteile",
|
||||
"cons": "Nachteile",
|
||||
"notes": "Notizen",
|
||||
"addCandidate": "Kandidat hinzufuegen"
|
||||
},
|
||||
"comparison": {
|
||||
"weight": "Gewicht",
|
||||
"price": "Preis",
|
||||
"pros": "Vorteile",
|
||||
"cons": "Nachteile"
|
||||
},
|
||||
"resolve": {
|
||||
"title": "Gewinner waehlen",
|
||||
"message": "<bold>{{name}}</bold> als Gewinner waehlen? Der Gegenstand wird Ihrer Sammlung hinzugefuegt und der Thread archiviert."
|
||||
},
|
||||
"empty": {
|
||||
"noThreads": "Noch keine Recherche-Threads",
|
||||
"noCandidates": "Noch keine Kandidaten"
|
||||
}
|
||||
}
|
||||
76
tests/i18n/locales.test.ts
Normal file
76
tests/i18n/locales.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
const LOCALES_DIR = join(import.meta.dir, "../../src/client/locales");
|
||||
|
||||
function flattenKeys(
|
||||
obj: Record<string, unknown>,
|
||||
prefix = "",
|
||||
): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
keys.push(
|
||||
...flattenKeys(value as Record<string, unknown>, fullKey),
|
||||
);
|
||||
} else {
|
||||
keys.push(fullKey);
|
||||
}
|
||||
}
|
||||
return keys.sort();
|
||||
}
|
||||
|
||||
function loadLocale(
|
||||
locale: string,
|
||||
): Record<string, Record<string, unknown>> {
|
||||
const dir = join(LOCALES_DIR, locale);
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
||||
const result: Record<string, Record<string, unknown>> = {};
|
||||
for (const file of files) {
|
||||
const ns = file.replace(".json", "");
|
||||
result[ns] = JSON.parse(readFileSync(join(dir, file), "utf8"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("locale key parity", () => {
|
||||
const en = loadLocale("en");
|
||||
const de = loadLocale("de");
|
||||
|
||||
test("en and de have the same namespaces", () => {
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(de).sort());
|
||||
});
|
||||
|
||||
for (const ns of Object.keys(en)) {
|
||||
test(`${ns}: every en key exists in de`, () => {
|
||||
const enKeys = flattenKeys(en[ns]);
|
||||
const deKeys = flattenKeys(de[ns]);
|
||||
const missing = enKeys.filter((k) => !deKeys.includes(k));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test(`${ns}: every de key exists in en`, () => {
|
||||
const enKeys = flattenKeys(en[ns]);
|
||||
const deKeys = flattenKeys(de[ns]);
|
||||
const orphan = deKeys.filter((k) => !enKeys.includes(k));
|
||||
expect(orphan).toEqual([]);
|
||||
});
|
||||
|
||||
test(`${ns}: no empty de values`, () => {
|
||||
const deKeys = flattenKeys(de[ns]);
|
||||
for (const key of deKeys) {
|
||||
const value = key
|
||||
.split(".")
|
||||
.reduce(
|
||||
(obj, k) => (obj as Record<string, unknown>)?.[k],
|
||||
de[ns] as unknown,
|
||||
);
|
||||
expect(
|
||||
typeof value === "string" && value.length > 0,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user