feat(i18n): add scripts for locale translation and patching
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s

- Implemented `fill-de-extended-gaps.js` to fill missing billing/orders keys in de-extended from de.
- Created `fill-i18n-deep.py` for deep translation of locale JSONs using deep-translator with fallback options.
- Added `fill-i18n-locales.js` to translate locale JSONs and write overrides for untranslated keys.
- Introduced `fix-en-leaks.py` to translate keys that still match the en-US merge, addressing English leaks.
- Developed `patch-de-ch-swiss.js` to replace 'ß' with 'ss' in de-CH.json without deleting existing entries.
- Created `patch-en-gb-au.js` to apply UK/AU spelling corrections in en-GB and en-AU locales.
- Added shell scripts `run-fix-en-leaks.sh` and `run-i18n-deep-fill.sh` for sequential execution of translation tasks.
- Implemented `update-i18n-todo-stats.js` to update statistics in the I18N_TODO.md file based on translation completeness.
This commit is contained in:
Torsten Schulz (local)
2026-05-15 15:52:54 +02:00
parent 320010b94e
commit eb54b4f7cf
54 changed files with 58003 additions and 30665 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Setzt bewusste Cognates/Produktnamen explizit in allen Locales (Wert = en-US oder de).
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const LOCALES_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales');
const LOCALES = [
'fr', 'es', 'it', 'pl', 'ja', 'zh', 'th', 'tl', 'fil',
'en-US', 'en-GB', 'en-AU',
];
// Keys, die in Nicht-DE-Locales explizit gesetzt werden (Wert aus en-US-Merge)
const COGNATE_KEYS = [
'common.filter', 'common.details', 'common.name', 'common.status',
'common.optional', 'common.in', 'common.ok',
'navigation.clickTtBrowser',
'members.rowTooltipSeparator', 'members.ttrQttr', 'members.status',
'members.clickTtRequestShort',
'diary.standardDurationShort', 'diary.min',
'trainingStats.name', 'trainingStats.ttr', 'trainingStats.qttr',
'tournaments.knockoutLabel', 'tournaments.genderMixed', 'tournaments.optional',
'tournaments.index', 'tournaments.diff',
'billing.iban',
'schedule.code', 'schedule.team', 'schedule.teams', 'schedule.vs',
'teamManagement.team', 'teamManagement.teams',
'courtDrawingTool.strokeTypeTopspin', 'courtDrawingTool.strokeTypeFlip',
'courtDrawingTool.strokeTypeBlock',
'matchReportApi.vs', 'courtDrawing.ok',
'pdfGenerator.diff', 'pdfGenerator.lineupQttr',
'pendingApprovals.email', 'permissions.email',
'logs.details', 'logs.logDetailsLabels.ip',
'dialogs.info.ok', 'dialogs.confirm.ok',
'memberTransferDialog.server', 'memberTransferDialog.endpoint', 'memberTransferDialog.format',
'myTischtennis.title', 'myTischtennisAccount.title', 'myTischtennisAccount.teams',
'memberNotes.tags', 'nuscoreAnalyzer.title',
'predefinedActivities.min',
];
const EXTRA = {
fr: { 'members.address': 'Adresse' },
es: { 'members.address': 'Dirección' },
it: { 'members.address': 'Indirizzo' },
pl: { 'members.address': 'Adres' },
ja: { 'members.address': '住所' },
zh: { 'members.address': '地址' },
th: { 'members.address': 'ที่อยู่' },
tl: { 'members.address': 'Address', 'languages.th': 'Thai (ไทย)' },
fil: { 'members.address': 'Address', 'languages.th': 'Thai (ไทย)' },
};
function flatten(obj, prefix = '', out = {}) {
for (const [key, value] of Object.entries(obj || {})) {
const nextKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
flatten(value, nextKey, out);
} else if (typeof value === 'string') {
out[nextKey] = value;
}
}
return out;
}
function deepMerge(base, override) {
if (!base || typeof base !== 'object' || Array.isArray(base)) return override ?? base;
const result = { ...base };
for (const [key, value] of Object.entries(override || {})) {
if (
value && typeof value === 'object' && !Array.isArray(value) &&
result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])
) {
result[key] = deepMerge(result[key], value);
} else {
result[key] = value;
}
}
return result;
}
function setByPath(obj, dotPath, value) {
const parts = dotPath.split('.');
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
}
function buildOverrides(deFlat, targetFlat) {
const out = {};
for (const [key, value] of Object.entries(targetFlat)) {
if (value !== deFlat[key]) setByPath(out, key, value);
}
return out;
}
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
const enUs = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'en-US.json'), 'utf8'));
const deFlat = flatten(de);
const enMerged = flatten(deepMerge(JSON.parse(JSON.stringify(de)), enUs));
for (const locale of LOCALES) {
const localePath = path.join(LOCALES_DIR, `${locale}.json`);
if (!fs.existsSync(localePath)) continue;
const localeJson = JSON.parse(fs.readFileSync(localePath, 'utf8'));
const merged = flatten(deepMerge(JSON.parse(JSON.stringify(de)), localeJson));
for (const key of COGNATE_KEYS) {
const val = enMerged[key] ?? deFlat[key];
if (val !== undefined) merged[key] = val;
}
for (const [key, val] of Object.entries(EXTRA[locale] || {})) {
merged[key] = val;
}
const overrides = buildOverrides(deFlat, merged);
fs.writeFileSync(localePath, `${JSON.stringify(overrides, null, 2)}\n`, 'utf8');
const stillDe = Object.keys(deFlat).filter((k) => merged[k] === deFlat[k]).length;
console.log(`${locale}: overrides=${Object.keys(flatten(overrides)).length}, stillDe=${stillDe}`);
}

120
scripts/apply-i18n-cache.py Normal file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Schreibt Locale-Overrides aus Cache + EN-Fallback ohne API-Aufrufe."""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOCALES_DIR = ROOT / "frontend" / "src" / "i18n" / "locales"
CACHE_FILE = Path(__file__).resolve().parent / ".i18n-translate-cache.json"
TARGETS = {
"fr": "fr",
"es": "es",
"it": "it",
"pl": "pl",
"ja": "ja",
"zh": "zh-CN",
"th": "th",
"tl": "tl",
"fil": "tl",
}
def deep_merge(base, override):
if not isinstance(base, dict) or isinstance(base, list):
return override if override is not None else base
result = dict(base)
for key, value in (override or {}).items():
if (
isinstance(value, dict)
and not isinstance(value, list)
and isinstance(result.get(key), dict)
and not isinstance(result.get(key), list)
):
result[key] = deep_merge(result[key], value)
else:
result[key] = value
return result
def flatten(obj, prefix=""):
out = {}
for key, value in (obj or {}).items():
next_key = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict) and not isinstance(value, list):
out.update(flatten(value, next_key))
elif isinstance(value, str):
out[next_key] = value
return out
def set_by_path(obj, dot_path, value):
parts = dot_path.split(".")
cur = obj
for part in parts[:-1]:
if part not in cur or not isinstance(cur[part], dict):
cur[part] = {}
cur = cur[part]
cur[parts[-1]] = value
def build_overrides(de_flat, target_flat):
out = {}
for key, value in target_flat.items():
if value != de_flat.get(key):
set_by_path(out, key, value)
return out
def apply_locale(code: str, cache: dict, en_flat: dict, de_flat: dict) -> None:
target = TARGETS[code]
locale_path = LOCALES_DIR / f"{code}.json"
locale_json = json.loads(locale_path.read_text(encoding="utf-8"))
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
merged = flatten(deep_merge(json.loads(json.dumps(de)), locale_json))
from_cache = from_en = still = 0
for key, de_val in de_flat.items():
if merged.get(key) != de_val:
continue
from_lang = "en" if en_flat.get(key) and en_flat[key] != de_val else "de"
text = en_flat[key] if from_lang == "en" else de_val
cache_key = f"{from_lang}|{target}|{text}"
if cache_key in cache:
merged[key] = cache[cache_key]
from_cache += 1
elif from_lang == "en":
merged[key] = text
from_en += 1
else:
still += 1
overrides = build_overrides(de_flat, merged)
locale_path.write_text(json.dumps(overrides, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(
f"[{code}] cache={from_cache} enFallback={from_en} stillDe={still} overrides={len(flatten(overrides))}",
flush=True,
)
def main():
codes = sys.argv[1:] or list(TARGETS.keys())
cache = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
en_us = json.loads((LOCALES_DIR / "en-US.json").read_text(encoding="utf-8"))
de_flat = flatten(de)
en_flat = flatten(deep_merge(de, en_us))
for code in codes:
if code not in TARGETS:
print(f"skip unknown: {code}", file=sys.stderr)
continue
apply_locale(code, cache, en_flat, de_flat)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* Vergleicht alle frontend/src/i18n/locales/*.json mit de.json (wie deepMerge in frontend/src/i18n/index.js).
*
* Metriken pro Locale (Blatt-Strings, flatten):
* - inheritedFromDe: Key fehlt in der Locale-Datei → Merge ergibt deutschen Text
* - explicitStillDe: Key ist in der Locale gesetzt, Wert ist identisch zu de (Copy-Paste / vergessen)
* - differsFromDe: abweichend von Deutsch (Zielsprache oder Englisch-Vorlage)
* - enUsLeak: unter differsFromDe: Wert identisch zum en-US-Merge (typisch EN-Vorlage statt Zielsprache)
*
* Usage:
* node scripts/check-i18n-completeness.js
* node scripts/check-i18n-completeness.js --top 25 --locale fil
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const SOURCE_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales');
function deepMergeMessages(base, override) {
if (!base || typeof base !== 'object' || Array.isArray(base)) {
return override ?? base;
}
const result = { ...base };
for (const [key, value] of Object.entries(override || {})) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
result[key] &&
typeof result[key] === 'object' &&
!Array.isArray(result[key])
) {
result[key] = deepMergeMessages(result[key], value);
} else {
result[key] = value;
}
}
return result;
}
function flatten(obj, prefix = '', out = {}) {
for (const [key, value] of Object.entries(obj || {})) {
const nextKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
flatten(value, nextKey, out);
} else if (typeof value === 'string') {
out[nextKey] = value;
}
}
return out;
}
/** Blatt-Keys, die in `override` explizit als String gesetzt sind */
function overriddenLeafKeys(override, prefix = '', out = new Set()) {
if (!override || typeof override !== 'object' || Array.isArray(override)) return out;
for (const [key, value] of Object.entries(override)) {
const p = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
overriddenLeafKeys(value, p, out);
} else if (typeof value === 'string') {
out.add(p);
}
}
return out;
}
function topPrefixes(keys, n = 12) {
const counts = {};
for (const k of keys) {
const root = k.split('.')[0];
counts[root] = (counts[root] || 0) + 1;
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, n);
}
function parseArgs(argv) {
let top = 15;
let locale = null;
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--top' && argv[i + 1]) {
top = parseInt(argv[++i], 10) || 15;
} else if (argv[i] === '--locale' && argv[i + 1]) {
locale = argv[++i];
}
}
return { top, locale };
}
function analyzeLocale(de, deFlat, deKeys, enUSMergedFlat, code) {
const loc = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, `${code}.json`), 'utf8'));
const merged = flatten(deepMergeMessages(JSON.parse(JSON.stringify(de)), loc));
const explicit = overriddenLeafKeys(loc);
const inheritedKeys = [];
const explicitDeKeys = [];
const enLeakKeys = [];
for (const k of deKeys) {
if (merged[k] === deFlat[k]) {
if (!explicit.has(k)) inheritedKeys.push(k);
else explicitDeKeys.push(k);
} else if (merged[k] === enUSMergedFlat[k]) {
enLeakKeys.push(k);
}
}
return {
code,
totalDe: deKeys.length,
explicitLeaves: explicit.size,
inheritedFromDe: inheritedKeys.length,
explicitStillDe: explicitDeKeys.length,
differsFromDe: deKeys.length - inheritedKeys.length - explicitDeKeys.length,
enUsLeak: enLeakKeys.length,
inheritedKeys,
explicitDeKeys,
enLeakKeys,
};
}
function main() {
const { top, locale: onlyLocale } = parseArgs(process.argv);
const de = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, 'de.json'), 'utf8'));
const deFlat = flatten(de);
const deKeys = Object.keys(deFlat).sort();
const enUS = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, 'en-US.json'), 'utf8'));
const enUSMergedFlat = flatten(deepMergeMessages(JSON.parse(JSON.stringify(de)), enUS));
const files = fs
.readdirSync(SOURCE_DIR)
.filter((f) => f.endsWith('.json') && !f.endsWith('.backup'))
.sort();
const rows = [];
for (const file of files) {
if (file === 'de.json') continue;
const code = path.basename(file, '.json');
if (onlyLocale && code !== onlyLocale) continue;
rows.push(analyzeLocale(de, deFlat, deKeys, enUSMergedFlat, code));
}
console.log(`Basis: de.json → ${deKeys.length} Blatt-Strings (flatten)\n`);
console.log(
[
'Locale'.padEnd(14),
'explicit'.padStart(8),
'erbtsDE'.padStart(8),
'=de expl'.padStart(10),
'≠de'.padStart(6),
'≈en-US'.padStart(8),
].join(' '),
);
console.log('-'.repeat(58));
for (const r of rows.sort((a, b) => b.inheritedFromDe - a.inheritedFromDe)) {
console.log(
[
r.code.padEnd(14),
String(r.explicitLeaves).padStart(8),
String(r.inheritedFromDe).padStart(8),
String(r.explicitStillDe).padStart(10),
String(r.differsFromDe).padStart(6),
String(r.enUsLeak).padStart(8),
].join(' '),
);
}
if (onlyLocale && rows.length === 1) {
const r = rows[0];
console.log(`\n--- Top-Namespace (erbte deutsche Strings) für ${r.code} ---`);
for (const [p, c] of topPrefixes(r.inheritedKeys, top)) {
console.log(` ${p}: ${c}`);
}
if (r.enLeakKeys.length) {
console.log(`\n--- Beispiel en-US-Leaks (erste ${Math.min(12, r.enLeakKeys.length)}) ---`);
for (const k of r.enLeakKeys.slice(0, 12)) {
console.log(` ${k}`);
}
}
}
console.log('\nLegende: erbtsDE = kein Blatt-Override in Locale-JSON (= deutsch); ≈en-US = Wert wie en-US-Merge');
}
main();

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* de-CH: Alle ererbten de-Strings als Override mit CH-Orthografie (ß→ss, …).
* Bestehende de-CH-Overrides bleiben erhalten.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const LOCALES_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales');
const CH_REPLACEMENTS = [
[/ß/g, 'ss'],
[/Straße/g, 'Strasse'],
[/straße/g, 'strasse'],
[/Grüße/g, 'Grüsse'],
[/grüße/g, 'grüsse'],
[/Gruß/g, 'Gruss'],
[/gruß/g, 'gruss'],
];
function toSwiss(text) {
let out = text;
for (const [re, rep] of CH_REPLACEMENTS) {
out = out.replace(re, rep);
}
return out;
}
function flatten(obj, prefix = '', out = {}) {
for (const [key, value] of Object.entries(obj || {})) {
const nextKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
flatten(value, nextKey, out);
} else if (typeof value === 'string') {
out[nextKey] = value;
}
}
return out;
}
function deepMerge(base, override) {
if (!base || typeof base !== 'object' || Array.isArray(base)) return override ?? base;
const result = { ...base };
for (const [key, value] of Object.entries(override || {})) {
if (
value && typeof value === 'object' && !Array.isArray(value) &&
result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])
) {
result[key] = deepMerge(result[key], value);
} else {
result[key] = value;
}
}
return result;
}
function setByPath(obj, dotPath, value) {
const parts = dotPath.split('.');
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
}
function buildOverrides(deFlat, targetFlat) {
const out = {};
for (const [key, value] of Object.entries(targetFlat)) {
if (value !== deFlat[key]) setByPath(out, key, value);
}
return out;
}
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
const ch = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de-CH.json'), 'utf8'));
const deFlat = flatten(de);
const merged = flatten(deepMerge(JSON.parse(JSON.stringify(de)), ch));
let patched = 0;
for (const key of Object.keys(deFlat)) {
if (merged[key] === deFlat[key]) {
const swiss = toSwiss(deFlat[key]);
if (swiss !== deFlat[key] || !Object.prototype.hasOwnProperty.call(flatten(ch), key)) {
merged[key] = swiss;
if (swiss !== deFlat[key]) patched++;
}
}
}
// Alle Keys: CH-Text als Override wenn abweichend von de
for (const key of Object.keys(deFlat)) {
const swiss = toSwiss(deFlat[key]);
if (merged[key] !== deFlat[key]) continue;
merged[key] = swiss;
}
const overrides = buildOverrides(deFlat, merged);
const outPath = path.join(LOCALES_DIR, 'de-CH.json');
fs.writeFileSync(outPath, `${JSON.stringify(overrides, null, 2)}\n`, 'utf8');
const stillDe = Object.keys(deFlat).filter((k) => merged[k] === deFlat[k]).length;
console.log(`de-CH: overrides=${Object.keys(flatten(overrides)).length}, ss-patched=${patched}, stillDe=${stillDe}`);

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env node
/** Ergänzt fehlende billing/orders-Keys in de-extended aus de. */
const fs = require('fs');
const path = require('path');
const LOCALES_DIR = path.join(__dirname, '../frontend/src/i18n/locales');
function flatten(obj, prefix = '', out = {}) {
for (const [key, value] of Object.entries(obj || {})) {
const nextKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) flatten(value, nextKey, out);
else if (typeof value === 'string') out[nextKey] = value;
}
return out;
}
function deepMerge(base, override) {
if (!base || typeof base !== 'object' || Array.isArray(base)) return override ?? base;
const result = { ...base };
for (const [key, value] of Object.entries(override || {})) {
if (
value && typeof value === 'object' && !Array.isArray(value) &&
result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])
) {
result[key] = deepMerge(result[key], value);
} else {
result[key] = value;
}
}
return result;
}
function setByPath(obj, dotPath, value) {
const parts = dotPath.split('.');
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
}
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
const ext = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de-extended.json'), 'utf8'));
const deFlat = flatten(de);
const merged = flatten(deepMerge(JSON.parse(JSON.stringify(de)), ext));
const NAMESPACES = ['billing', 'orders'];
let added = 0;
for (const key of Object.keys(deFlat)) {
if (!NAMESPACES.some((ns) => key.startsWith(`${ns}.`))) continue;
if (!(key in flatten(ext)) || merged[key] === deFlat[key]) {
setByPath(ext, key, deFlat[key]);
merged[key] = deFlat[key];
added++;
}
}
fs.writeFileSync(
path.join(LOCALES_DIR, 'de-extended.json'),
`${JSON.stringify(ext, null, 2)}\n`,
'utf8'
);
const stillDe = Object.keys(deFlat).filter((k) => merged[k] === deFlat[k]).length;
console.log(`de-extended: added ${added} keys, stillDe=${stillDe}`);

237
scripts/fill-i18n-deep.py Executable file
View File

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Füllt Locale-JSONs via deep-translator (Fallback wenn MyMemory/Google limitiert)."""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from pathlib import Path
from deep_translator import GoogleTranslator
ROOT = Path(__file__).resolve().parents[1]
LOCALES_DIR = ROOT / "frontend" / "src" / "i18n" / "locales"
CACHE_FILE = Path(__file__).resolve().parent / ".i18n-translate-cache.json"
LOCALE_TARGETS = {
"fr": "fr",
"es": "es",
"it": "it",
"pl": "pl",
"ja": "ja",
"zh": "zh-CN",
"th": "th",
"tl": "tl",
"fil": "tl",
}
SKIP = {"de", "de-extended", "de-CH", "en-US", "en-GB", "en-AU"}
FILL_ORDER = ["fr", "es", "it", "pl", "ja", "zh", "th", "tl", "fil"]
PLACEHOLDER_RE = re.compile(r"\{[^}]+\}")
def deep_merge(base, override):
if not isinstance(base, dict) or isinstance(base, list):
return override if override is not None else base
result = dict(base)
for key, value in (override or {}).items():
if (
isinstance(value, dict)
and not isinstance(value, list)
and isinstance(result.get(key), dict)
and not isinstance(result.get(key), list)
):
result[key] = deep_merge(result[key], value)
else:
result[key] = value
return result
def flatten(obj, prefix=""):
out = {}
for key, value in (obj or {}).items():
next_key = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict) and not isinstance(value, list):
out.update(flatten(value, next_key))
elif isinstance(value, str):
out[next_key] = value
return out
def set_by_path(obj, dot_path, value):
parts = dot_path.split(".")
cur = obj
for part in parts[:-1]:
if part not in cur or not isinstance(cur[part], dict):
cur[part] = {}
cur = cur[part]
cur[parts[-1]] = value
def build_overrides(de_flat, target_flat):
out = {}
for key, value in target_flat.items():
if value != de_flat.get(key):
set_by_path(out, key, value)
return out
def protect_placeholders(text):
tokens = []
def repl(m):
token = f"__PH{len(tokens)}__"
tokens.append(m.group(0))
return token
return PLACEHOLDER_RE.sub(repl, text), tokens
def restore_placeholders(text, tokens):
out = text
for i, token in enumerate(tokens):
out = out.replace(f"__PH{i}__", token)
return out
def load_cache():
if CACHE_FILE.exists():
return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
return {}
def save_cache(cache):
CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def fill_de_ch(de_flat, merged_flat):
for key in de_flat:
if merged_flat.get(key) == de_flat[key]:
merged_flat[key] = de_flat[key].replace("ß", "ss")
def translate_text(translator, cache, text, from_lang, to_lang, delay, timeout=20):
cache_key = f"{from_lang}|{to_lang}|{text}"
if cache_key in cache:
return cache[cache_key]
safe, tokens = protect_placeholders(text)
def _call():
return translator.translate(safe)
last_err = None
for attempt in range(3):
try:
with ThreadPoolExecutor(max_workers=1) as pool:
raw = pool.submit(_call).result(timeout=timeout)
out = restore_placeholders(raw, tokens)
cache[cache_key] = out
time.sleep(delay)
return out
except (FuturesTimeout, Exception) as e:
last_err = e
time.sleep(2 + attempt * 2)
raise last_err
def fill_locale(code, en_flat, cache, delay, dry_run):
target = LOCALE_TARGETS[code]
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
de_flat = flatten(de)
locale_path = LOCALES_DIR / f"{code}.json"
locale_json = json.loads(locale_path.read_text(encoding="utf-8"))
merged_flat = flatten(deep_merge(json.loads(json.dumps(de)), locale_json))
if code == "de-CH":
fill_de_ch(de_flat, merged_flat)
else:
keys_to_fix = [k for k in de_flat if merged_flat.get(k) == de_flat[k]]
unique = {}
for k in keys_to_fix:
from_lang = "en" if en_flat.get(k) and en_flat[k] != de_flat[k] else "de"
text = en_flat[k] if from_lang == "en" else de_flat[k]
unique.setdefault((from_lang, text), []).append(k)
print(f"[{code}] {len(keys_to_fix)} keys, {len(unique)} unique → {target}", flush=True)
by_source = {"en": [], "de": []}
for (from_lang, text), keys in unique.items():
by_source[from_lang].append((text, keys))
done = 0
for from_lang in ("en", "de"):
items = by_source[from_lang]
if not items:
continue
translator = GoogleTranslator(source=from_lang, target=target)
for text, keys in items:
cache_key = f"{from_lang}|{target}|{text}"
try:
if dry_run:
translated = f"[{target}] {text[:30]}"
elif cache_key in cache:
translated = cache[cache_key]
else:
translated = translate_text(translator, cache, text, from_lang, target, delay)
for k in keys:
merged_flat[k] = translated
done += 1
if done % 50 == 0:
print(f"[{code}] {done}/{len(unique)}", flush=True)
save_cache(cache)
except Exception as e:
print(f"[{code}] skip: {text[:40]}… ({e})", file=sys.stderr, flush=True)
if from_lang == "en" and en_flat:
for k in keys:
merged_flat[k] = text
save_cache(cache)
overrides = build_overrides(de_flat, merged_flat)
if not dry_run:
locale_path.write_text(
json.dumps(overrides, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
still_de = sum(1 for k in de_flat if merged_flat.get(k) == de_flat[k])
print(f"[{code}] overrides={len(flatten(overrides))}, stillDe={still_de}", flush=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--locale")
parser.add_argument("--all", action="store_true")
parser.add_argument("--from-locale", dest="from_locale", help="Bei --all: ab dieser Locale fortsetzen")
parser.add_argument("--delay", type=float, default=0.35)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
codes = FILL_ORDER if args.all else [args.locale] if args.locale else None
if codes and args.from_locale:
if args.from_locale not in FILL_ORDER:
parser.error(f"Unknown locale: {args.from_locale}")
codes = FILL_ORDER[FILL_ORDER.index(args.from_locale) :]
if not codes:
parser.error("Usage: --locale <code> | --all")
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
en_us = json.loads((LOCALES_DIR / "en-US.json").read_text(encoding="utf-8"))
en_flat = flatten(deep_merge(de, en_us))
cache = load_cache()
for code in codes:
if code in SKIP or code not in LOCALE_TARGETS:
continue
fill_locale(code, en_flat, cache, args.delay, args.dry_run)
time.sleep(5)
save_cache(cache)
print("Done.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,385 @@
#!/usr/bin/env node
/**
* Füllt Locale-JSONs: Blätter, die nach deepMerge(de, locale) noch Deutsch sind,
* werden übersetzt (MyMemory, Fallback Google) und als Override geschrieben.
*
* Cache: scripts/.i18n-translate-cache.json
*
* Usage:
* node scripts/fill-i18n-locales.js --locale en-US
* node scripts/fill-i18n-locales.js --all --delay 450
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const LOCALES_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales');
const CACHE_FILE = path.join(__dirname, '.i18n-translate-cache.json');
const TRANSLATE_PKG = path.join(ROOT, 'frontend', 'node_modules', '@vitalets/google-translate-api');
const PLACEHOLDER_RE = /\{[^}]+\}/g;
const LOCALE_TARGETS = {
'en-US': 'en',
'en-GB': 'en',
'en-AU': 'en',
es: 'es',
fr: 'fr',
it: 'it',
pl: 'pl',
ja: 'ja',
zh: 'zh-CN',
th: 'th',
tl: 'tl',
fil: 'tl',
'de-CH': 'de',
};
const SKIP_LOCALES = new Set(['de', 'de-extended']);
const ALL_FILL_ORDER = [
'en-US',
'en-GB',
'en-AU',
'fr',
'es',
'it',
'pl',
'ja',
'zh',
'th',
'tl',
'fil',
'de-CH',
];
function deepMergeMessages(base, override) {
if (!base || typeof base !== 'object' || Array.isArray(base)) {
return override ?? base;
}
const result = { ...base };
for (const [key, value] of Object.entries(override || {})) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
result[key] &&
typeof result[key] === 'object' &&
!Array.isArray(result[key])
) {
result[key] = deepMergeMessages(result[key], value);
} else {
result[key] = value;
}
}
return result;
}
function flatten(obj, prefix = '', out = {}) {
for (const [key, value] of Object.entries(obj || {})) {
const nextKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
flatten(value, nextKey, out);
} else if (typeof value === 'string') {
out[nextKey] = value;
}
}
return out;
}
function setByPath(obj, dotPath, value) {
const parts = dotPath.split('.');
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') {
cur[parts[i]] = {};
}
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
}
function buildOverrides(deFlat, targetFlat) {
const out = {};
for (const [key, value] of Object.entries(targetFlat)) {
if (value !== deFlat[key]) {
setByPath(out, key, value);
}
}
return out;
}
function protectPlaceholders(text) {
const tokens = [];
const safe = text.replace(PLACEHOLDER_RE, (m) => {
const token = `__PH${tokens.length}__`;
tokens.push(m);
return token;
});
return { safe, tokens };
}
function restorePlaceholders(text, tokens) {
let out = text;
for (let i = 0; i < tokens.length; i++) {
out = out.replace(new RegExp(`__\\s*PH\\s*${i}\\s*__`, 'gi'), tokens[i]);
out = out.replace(`__PH${i}__`, tokens[i]);
}
return out;
}
function loadCache() {
if (!fs.existsSync(CACHE_FILE)) return {};
try {
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
} catch {
return {};
}
}
function saveCache(cache) {
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), 'utf8');
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function parseArgs() {
const args = process.argv.slice(2);
let locale = null;
let all = false;
let delay = 450;
let dryRun = false;
let noGoogle = false;
let enFallback = false;
let skipEn = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--locale' && args[i + 1]) locale = args[++i];
else if (args[i] === '--all') all = true;
else if (args[i] === '--delay' && args[i + 1]) delay = parseInt(args[++i], 10) || 450;
else if (args[i] === '--dry-run') dryRun = true;
else if (args[i] === '--no-google') noGoogle = true;
else if (args[i] === '--en-fallback') enFallback = true;
else if (args[i] === '--skip-en') skipEn = true;
}
return { locale, all, delay, dryRun, noGoogle, enFallback, skipEn };
}
async function mymemoryTranslate(text, from, to) {
const url = new URL('https://api.mymemory.translated.net/get');
url.searchParams.set('q', text.slice(0, 450));
url.searchParams.set('langpair', `${from}|${to}`);
const res = await fetch(url);
const data = await res.json();
if (data.quotaFinished) {
throw new Error('MyMemory quota finished');
}
if (data.responseStatus !== 200) {
throw new Error(data.responseDetails || `MyMemory status ${data.responseStatus}`);
}
return data.responseData.translatedText;
}
async function googleTranslate(translateFn, text, from, to) {
const res = await translateFn(text, { from, to });
return res.text;
}
async function translateText(providers, text, from, to, cache) {
const cacheKey = `${from}|${to}|${text}`;
if (cache[cacheKey]) return cache[cacheKey];
const { safe, tokens } = protectPlaceholders(text);
let out;
let lastErr;
for (const provider of providers) {
try {
const raw = await provider(safe, from, to);
out = restorePlaceholders(raw, tokens);
out = out.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
cache[cacheKey] = out;
return out;
} catch (e) {
lastErr = e;
await sleep(2000);
}
}
throw lastErr;
}
function bootstrapEnFromGb(deFlat, mergedFlat) {
const gbJson = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'en-GB.json'), 'utf8'));
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
const gbFlat = flatten(deepMergeMessages(de, gbJson));
let n = 0;
for (const k of Object.keys(deFlat)) {
if (mergedFlat[k] === deFlat[k] && gbFlat[k] && gbFlat[k] !== deFlat[k]) {
mergedFlat[k] = gbFlat[k];
n++;
}
}
return n;
}
function bootstrapEnFromUs(deFlat, mergedFlat, enFlat) {
if (!enFlat) return 0;
let n = 0;
for (const k of Object.keys(deFlat)) {
if (mergedFlat[k] === deFlat[k] && enFlat[k] && enFlat[k] !== deFlat[k]) {
mergedFlat[k] = enFlat[k];
n++;
}
}
return n;
}
async function fillLocale(code, opts) {
const { providers, cache, delay, dryRun, enFlat, enFallback } = opts;
const targetLang = LOCALE_TARGETS[code];
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
const deFlat = flatten(de);
const localePath = path.join(LOCALES_DIR, `${code}.json`);
const localeJson = JSON.parse(fs.readFileSync(localePath, 'utf8'));
const mergedFlat = flatten(deepMergeMessages(JSON.parse(JSON.stringify(de)), localeJson));
if (code === 'en-US' || code === 'en-AU') {
const copied = bootstrapEnFromGb(deFlat, mergedFlat);
if (copied) console.log(`[${code}] bootstrapped ${copied} strings from en-GB`);
}
if ((code === 'en-GB' || code === 'en-AU') && enFlat) {
const copied = bootstrapEnFromUs(deFlat, mergedFlat, enFlat);
if (copied) console.log(`[${code}] bootstrapped ${copied} strings from en-US`);
}
const keysToFix = Object.keys(deFlat).filter((k) => mergedFlat[k] === deFlat[k]);
if (!keysToFix.length) {
console.log(`[${code}] nothing to fill`);
if (!dryRun) {
fs.writeFileSync(localePath, `${JSON.stringify(buildOverrides(deFlat, mergedFlat), null, 2)}\n`, 'utf8');
}
return;
}
const uniqueTexts = new Map();
for (const k of keysToFix) {
const from = enFlat && enFlat[k] && enFlat[k] !== deFlat[k] ? 'en' : 'de';
const text = from === 'en' ? enFlat[k] : deFlat[k];
const mapKey = `${from}\0${text}`;
if (!uniqueTexts.has(mapKey)) uniqueTexts.set(mapKey, { from, text, keys: [] });
uniqueTexts.get(mapKey).keys.push(k);
}
console.log(`[${code}] ${keysToFix.length} keys, ${uniqueTexts.size} unique, target=${targetLang}`);
if (code === 'de-CH') {
for (const k of keysToFix) {
const base = deFlat[k];
mergedFlat[k] = base.replace(/ß/g, 'ss');
}
} else {
let done = 0;
const failed = [];
for (const entry of uniqueTexts.values()) {
const { from, text, keys } = entry;
const cacheKey = `${from}|${targetLang}|${text}`;
let translated;
if (from === 'en' && targetLang === 'en') {
translated = text;
} else if (cache[cacheKey]) {
translated = cache[cacheKey];
} else if (dryRun) {
translated = `[${targetLang}] ${text.slice(0, 30)}`;
} else {
try {
translated = await translateText(providers, text, from, targetLang, cache);
await sleep(delay);
} catch (e) {
console.error(`[${code}] skip: ${text.slice(0, 50)}… (${e.message})`);
failed.push(entry);
continue;
}
}
for (const k of keys) mergedFlat[k] = translated;
done++;
if (done % 50 === 0) {
console.log(`[${code}] ${done}/${uniqueTexts.size} (failed ${failed.length})`);
saveCache(cache);
}
}
saveCache(cache);
if (failed.length && !dryRun) {
console.log(`[${code}] retrying ${failed.length} failed strings…`);
await sleep(5000);
for (const { from, text, keys } of failed) {
const cacheKey = `${from}|${targetLang}|${text}`;
try {
const translated = cache[cacheKey] || (await translateText(providers, text, from, targetLang, cache));
for (const k of keys) mergedFlat[k] = translated;
await sleep(delay);
} catch (e) {
if (enFallback && from === 'en') {
for (const k of keys) mergedFlat[k] = text;
} else {
console.error(`[${code}] final skip: ${text.slice(0, 40)}`);
}
}
}
saveCache(cache);
}
}
const overrides = buildOverrides(deFlat, mergedFlat);
if (!dryRun) {
fs.writeFileSync(localePath, `${JSON.stringify(overrides, null, 2)}\n`, 'utf8');
}
const stillDe = Object.keys(deFlat).filter((k) => mergedFlat[k] === deFlat[k]).length;
console.log(`[${code}] overrides=${Object.keys(flatten(overrides)).length}, stillDe=${stillDe}`);
}
async function main() {
const { locale, all, delay, dryRun, noGoogle, enFallback, skipEn } = parseArgs();
const providers = [
(text, from, to) => mymemoryTranslate(text, from, to),
];
if (!noGoogle && fs.existsSync(TRANSLATE_PKG)) {
const { translate: translateFn } = require(TRANSLATE_PKG);
providers.push((text, from, to) => googleTranslate(translateFn, text, from, to));
}
const cache = loadCache();
let codes = all ? ALL_FILL_ORDER : locale ? [locale] : null;
if (skipEn && codes) {
codes = codes.filter((c) => !['en-US', 'en-GB', 'en-AU'].includes(c));
}
if (!codes) {
console.error('Usage: --locale <code> | --all [--delay ms]');
process.exit(1);
}
let enFlat = null;
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
const enUsPath = path.join(LOCALES_DIR, 'en-US.json');
if (fs.existsSync(enUsPath)) {
enFlat = flatten(deepMergeMessages(de, JSON.parse(fs.readFileSync(enUsPath, 'utf8'))));
}
for (const code of codes) {
if (SKIP_LOCALES.has(code)) continue;
if (!(code in LOCALE_TARGETS)) continue;
await fillLocale(code, { providers, cache, delay, dryRun, enFlat, enFallback });
if (all && !dryRun) await sleep(8000);
if (code === 'en-US' && !dryRun) {
enFlat = flatten(deepMergeMessages(de, JSON.parse(fs.readFileSync(enUsPath, 'utf8'))));
}
}
saveCache(cache);
console.log('Done.');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

196
scripts/fix-en-leaks.py Normal file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Übersetzt Keys, deren Locale-Wert noch dem en-US-Merge entspricht (EN-Leak)."""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from pathlib import Path
from deep_translator import GoogleTranslator
ROOT = Path(__file__).resolve().parents[1]
LOCALES_DIR = ROOT / "frontend" / "src" / "i18n" / "locales"
CACHE_FILE = Path(__file__).resolve().parent / ".i18n-translate-cache.json"
TARGETS = {
"fr": "fr", "es": "es", "it": "it", "pl": "pl",
"ja": "ja", "zh": "zh-CN", "th": "th", "tl": "tl", "fil": "tl",
}
PLACEHOLDER_RE = re.compile(r"\{[^}]+\}")
def deep_merge(base, override):
if not isinstance(base, dict) or isinstance(base, list):
return override if override is not None else base
result = dict(base)
for key, value in (override or {}).items():
if (
isinstance(value, dict) and not isinstance(value, list)
and isinstance(result.get(key), dict) and not isinstance(result.get(key), list)
):
result[key] = deep_merge(result[key], value)
else:
result[key] = value
return result
def flatten(obj, prefix=""):
out = {}
for key, value in (obj or {}).items():
next_key = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict) and not isinstance(value, list):
out.update(flatten(value, next_key))
elif isinstance(value, str):
out[next_key] = value
return out
def set_by_path(obj, dot_path, value):
parts = dot_path.split(".")
cur = obj
for part in parts[:-1]:
if part not in cur or not isinstance(cur[part], dict):
cur[part] = {}
cur = cur[part]
cur[parts[-1]] = value
def build_overrides(de_flat, target_flat):
out = {}
for key, value in target_flat.items():
if value != de_flat.get(key):
set_by_path(out, key, value)
return out
def protect_placeholders(text):
tokens = []
def repl(m):
token = f"__PH{len(tokens)}__"
tokens.append(m.group(0))
return token
return PLACEHOLDER_RE.sub(repl, text), tokens
def restore_placeholders(text, tokens):
out = text
for i, token in enumerate(tokens):
out = out.replace(f"__PH{i}__", token)
return out
def load_cache():
if CACHE_FILE.exists():
return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
return {}
def save_cache(cache):
CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def translate_en(translator, cache, text, target, delay, timeout=25):
cache_key = f"en|{target}|{text}"
if cache_key in cache:
return cache[cache_key]
safe, tokens = protect_placeholders(text)
def _call():
return translator.translate(safe)
last_err = None
for attempt in range(3):
try:
with ThreadPoolExecutor(max_workers=1) as pool:
raw = pool.submit(_call).result(timeout=timeout)
out = restore_placeholders(raw, tokens)
cache[cache_key] = out
time.sleep(delay)
return out
except (FuturesTimeout, Exception) as e:
last_err = e
time.sleep(2 + attempt * 2)
raise last_err
def fix_locale(code, de_flat, en_flat, cache, delay, dry_run):
target = TARGETS[code]
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
locale_path = LOCALES_DIR / f"{code}.json"
locale_json = json.loads(locale_path.read_text(encoding="utf-8"))
merged = flatten(deep_merge(json.loads(json.dumps(de)), locale_json))
leaks = [
k for k in de_flat
if merged.get(k) == en_flat.get(k) and en_flat.get(k) != de_flat.get(k)
]
unique_texts = {}
for k in leaks:
text = en_flat[k]
unique_texts.setdefault(text, []).append(k)
print(f"[{code}] {len(leaks)} EN-leaks, {len(unique_texts)} unique → {target}", flush=True)
if not unique_texts:
return
translator = GoogleTranslator(source="en", target=target)
done = 0
for text, keys in unique_texts.items():
try:
if dry_run:
translated = f"[{target}] {text[:25]}"
else:
translated = translate_en(translator, cache, text, target, delay)
for k in keys:
merged[k] = translated
done += 1
if done % 50 == 0:
print(f"[{code}] {done}/{len(unique_texts)}", flush=True)
save_cache(cache)
except Exception as e:
print(f"[{code}] skip: {text[:40]}… ({e})", file=sys.stderr, flush=True)
save_cache(cache)
overrides = build_overrides(de_flat, merged)
if not dry_run:
locale_path.write_text(json.dumps(overrides, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
en_leaks_left = sum(
1 for k in de_flat
if merged.get(k) == en_flat.get(k) and en_flat.get(k) != de_flat.get(k)
)
print(f"[{code}] overrides={len(flatten(overrides))}, enLeaksLeft={en_leaks_left}", flush=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("locales", nargs="*", default=list(TARGETS.keys()))
parser.add_argument("--delay", type=float, default=0.2)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
en_us = json.loads((LOCALES_DIR / "en-US.json").read_text(encoding="utf-8"))
de_flat = flatten(de)
en_flat = flatten(deep_merge(json.loads(json.dumps(de)), en_us))
cache = load_cache()
for code in args.locales:
if code not in TARGETS:
print(f"skip {code}", file=sys.stderr)
continue
fix_locale(code, de_flat, en_flat, cache, args.delay, args.dry_run)
time.sleep(3)
save_cache(cache)
print("Done.", flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
/** Setzt nur ß→ss Overrides in de-CH.json, ohne bestehende Einträge zu löschen. */
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const LOCALES_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales');
function flatten(obj, prefix = '', out = {}) {
for (const [key, value] of Object.entries(obj || {})) {
const nextKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
flatten(value, nextKey, out);
} else if (typeof value === 'string') {
out[nextKey] = value;
}
}
return out;
}
function setByPath(obj, dotPath, value) {
const parts = dotPath.split('.');
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
}
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
const deFlat = flatten(de);
const chPath = path.join(LOCALES_DIR, 'de-CH.json');
const ch = JSON.parse(fs.readFileSync(chPath, 'utf8'));
const chFlat = flatten(ch);
let n = 0;
for (const [key, deVal] of Object.entries(deFlat)) {
if (!deVal.includes('ß')) continue;
const swiss = deVal.replace(/ß/g, 'ss');
if (chFlat[key] !== swiss) {
setByPath(ch, key, swiss);
n++;
}
}
fs.writeFileSync(chPath, `${JSON.stringify(ch, null, 2)}\n`, 'utf8');
console.log(`de-CH: ${n} ß→ss patches applied`);

47
scripts/patch-en-gb-au.js Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env node
/** UK/AU-Orthografie in en-GB / en-AU (gegenüber en-US). */
const fs = require('fs');
const path = require('path');
const LOCALES_DIR = path.join(__dirname, '../frontend/src/i18n/locales');
const UK_MAP = [
[/\bcolor\b/gi, (m) => (m[0] === 'C' ? 'Colour' : 'colour')],
[/\bcolors\b/gi, (m) => (m[0] === 'C' ? 'Colours' : 'colours')],
[/\borganize\b/gi, (m) => (m[0] === 'O' ? 'Organise' : 'organise')],
[/\borganized\b/gi, (m) => (m[0] === 'O' ? 'Organised' : 'organised')],
[/\bcenter\b/gi, (m) => (m[0] === 'C' ? 'Centre' : 'centre')],
[/\bfavorite\b/gi, (m) => (m[0] === 'F' ? 'Favourite' : 'favourite')],
[/\bfavorites\b/gi, (m) => (m[0] === 'F' ? 'Favourites' : 'favourites')],
[/\blabor\b/gi, (m) => (m[0] === 'L' ? 'Labour' : 'labour')],
[/\bdefense\b/gi, (m) => (m[0] === 'D' ? 'Defence' : 'defence')],
];
function applyUk(text) {
let out = text;
for (const [re, repl] of UK_MAP) {
out = out.replace(re, repl);
}
return out;
}
function walk(obj, fn) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string') obj[key] = fn(value);
else if (value && typeof value === 'object') walk(value, fn);
}
}
for (const locale of ['en-GB', 'en-AU']) {
const p = path.join(LOCALES_DIR, `${locale}.json`);
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
let n = 0;
walk(data, (s) => {
const t = applyUk(s);
if (t !== s) n++;
return t;
});
fs.writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
console.log(`${locale}: ${n} UK spelling patches`);
}

19
scripts/run-fix-en-leaks.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PY="$ROOT/scripts/.venv-i18n/bin/python"
LOG="/tmp/fix-en-leaks.log"
LOCALES=(th tl fil fr es it pl ja zh)
: >"$LOG"
echo "[$(date -Iseconds)] start" | tee -a "$LOG"
for loc in "${LOCALES[@]}"; do
echo "[$(date -Iseconds)] === $loc ===" | tee -a "$LOG"
PYTHONUNBUFFERED=1 "$PY" -u "$ROOT/scripts/fix-en-leaks.py" "$loc" --delay 0.15 2>&1 | tee -a "$LOG" || {
echo "[$(date -Iseconds)] FAILED $loc" | tee -a "$LOG"
exit 1
}
done
echo "[$(date -Iseconds)] ALL DONE" | tee -a "$LOG"

21
scripts/run-i18n-deep-fill.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Sequentieller Deep-Fill eine Locale nach der anderen, mit Fortschritts-Log.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PY="$ROOT/scripts/.venv-i18n/bin/python"
LOG="${TMPDIR:-/tmp}/i18n-deep-fill.log"
# Nur noch offene Locales (fr/es/it bereits gefüllt)
LOCALES=(pl ja zh th tl fil)
: >"$LOG"
echo "[$(date -Iseconds)] start" | tee -a "$LOG"
for loc in "${LOCALES[@]}"; do
echo "[$(date -Iseconds)] === $loc ===" | tee -a "$LOG"
if ! PYTHONUNBUFFERED=1 "$PY" -u "$ROOT/scripts/fill-i18n-deep.py" --locale "$loc" --delay 0.2 2>&1 | tee -a "$LOG"; then
echo "[$(date -Iseconds)] FAILED $loc" | tee -a "$LOG"
exit 1
fi
done
echo "[$(date -Iseconds)] ALL DONE" | tee -a "$LOG"

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* Aktualisiert Kennzahlen-Tabellen in frontend/I18N_TODO.md aus check-i18n-completeness.
* Usage: node scripts/update-i18n-todo-stats.js
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const TODO = path.join(ROOT, 'frontend', 'I18N_TODO.md');
const LOCALES = [
'de-CH',
'de-extended',
'en-US',
'en-GB',
'en-AU',
'es',
'fr',
'it',
'pl',
'ja',
'zh',
'th',
'tl',
'fil',
];
function parseStats() {
const out = execSync(`node "${path.join(__dirname, 'check-i18n-completeness.js')}"`, {
cwd: ROOT,
encoding: 'utf8',
});
const stats = {};
for (const line of out.split('\n')) {
const m = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
if (m && LOCALES.includes(m[1])) {
stats[m[1]] = {
explicit: m[2],
erbtsDE: m[3],
deExpl: m[4],
neDe: m[5],
enUs: m[6],
};
}
}
return stats;
}
function replaceTable(md, locale, row) {
const header = `## \`${locale}\``;
const idx = md.indexOf(header);
if (idx === -1) return md;
const tableStart = md.indexOf('| explicit |', idx);
const tableEnd = md.indexOf('\n\n### Aufgaben', tableStart);
if (tableStart === -1 || tableEnd === -1) return md;
const newTable = `| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|----------|---------|----------|-----|--------|
| ${row.explicit} | ${row.erbtsDE} | ${row.deExpl} | ${row.neDe} | ${row.enUs} |`;
return md.slice(0, tableStart) + newTable + md.slice(tableEnd);
}
function main() {
const stats = parseStats();
let md = fs.readFileSync(TODO, 'utf8');
const date = new Date().toISOString().slice(0, 10);
md = md.replace(
/Stand der Kennzahlen: \*\*[\d-]+\*\*/,
`Stand der Kennzahlen: **${date}**`
);
for (const locale of LOCALES) {
if (stats[locale]) md = replaceTable(md, locale, stats[locale]);
}
fs.writeFileSync(TODO, md, 'utf8');
console.log('Updated', TODO, 'for', Object.keys(stats).length, 'locales');
}
main();