diff --git a/tools/i18n/.gitignore b/tools/i18n/.gitignore new file mode 100644 index 000000000..47eea657d --- /dev/null +++ b/tools/i18n/.gitignore @@ -0,0 +1,2 @@ +missing/ +patches/ diff --git a/tools/i18n/README.md b/tools/i18n/README.md new file mode 100644 index 000000000..d65e929de --- /dev/null +++ b/tools/i18n/README.md @@ -0,0 +1,52 @@ +# i18n fill-missing tool + +One-shot helper for bringing every locale file in `apps/web/src/assets/i18n/` +to full coverage of `en.json`. + +## How it works + +`en.json` is the source of truth. For every other locale, the script: + +1. Walks `en.json` in order (top-level + nested). +2. For each leaf key: + - Keeps the locale's existing translation if present. + - Otherwise pulls the translation from `tools/i18n/patches/.json` + (a flat object keyed by dotted path). + - If neither exists, leaves the key absent and exits non-zero. +3. Writes the merged tree back to the locale file with 4-space indent and a + trailing newline. + +Existing translations are never overwritten. + +## Workflow + +```bash +# 1. Generate one missing-strings dump per locale (flat dotted-path → EN string) +node tools/i18n/fill-missing.mjs --emit-missing +# → tools/i18n/missing/.json + +# 2. Translate each missing/.json into the equivalent patch file +# in tools/i18n/patches/.json (same shape: { dotted.path: translated }). + +# 3. Merge patches into the locale files +node tools/i18n/fill-missing.mjs + +# 4. Verify +node -e " +const fs=require('fs'); +const en=JSON.parse(fs.readFileSync('apps/web/src/assets/i18n/en.json','utf8')); +const collect=(d,p='')=>Object.entries(d).flatMap(([k,v])=>v&&typeof v==='object'?collect(v,p?p+'.'+k:k):[p?p+'.'+k:k]); +const enKeys=new Set(collect(en)); +for(const f of fs.readdirSync('apps/web/src/assets/i18n').filter(x=>x.endsWith('.json')&&x!=='en.json')){ + const k=new Set(collect(JSON.parse(fs.readFileSync('apps/web/src/assets/i18n/'+f,'utf8')))); + console.log(f,'missing:',[...enKeys].filter(x=>!k.has(x)).length,'extra:',[...k].filter(x=>!enKeys.has(x)).length); +}" +``` + +## Translation rules + +- Preserve `{{interpolation}}` placeholders verbatim. +- Preserve any inline HTML tags. +- Match the locale's existing punctuation, casing, and terminology. +- Keep brand names (IPTVnator, Xtream, Stalker, M3U, EPG, MPV, VLC) untranslated. +- Short UI strings stay short — no length blow-up. diff --git a/tools/i18n/fill-missing.mjs b/tools/i18n/fill-missing.mjs new file mode 100644 index 000000000..c6dcab187 --- /dev/null +++ b/tools/i18n/fill-missing.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * Mechanical i18n merger. + * + * Modes: + * node tools/i18n/fill-missing.mjs --emit-missing Dump tools/i18n/missing/.json + * listing every missing key with the EN value. + * node tools/i18n/fill-missing.mjs Merge tools/i18n/patches/.json into + * apps/web/src/assets/i18n/.json, + * preserving existing translations and the + * key order from en.json. + * + * The script never invents translations. If a key is missing from both the locale file + * and the patch file, it is left absent and the script exits non-zero. + */ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '..', '..'); +const I18N_DIR = resolve(REPO_ROOT, 'apps/web/src/assets/i18n'); +const PATCH_DIR = resolve(__dirname, 'patches'); +const MISSING_DIR = resolve(__dirname, 'missing'); + +const args = new Set(process.argv.slice(2)); +const EMIT_MISSING = args.has('--emit-missing'); + +function readJSON(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +function writeJSON(path, obj) { + writeFileSync(path, JSON.stringify(obj, null, 4) + '\n'); +} + +function collectLeaves(obj, prefix = '') { + const leaves = []; + for (const [key, val] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + if (val && typeof val === 'object' && !Array.isArray(val)) { + leaves.push(...collectLeaves(val, path)); + } else { + leaves.push(path); + } + } + return leaves; +} + +function getByPath(obj, path) { + return path + .split('.') + .reduce((cur, k) => (cur == null ? cur : cur[k]), obj); +} + +/** + * Build a new object whose shape mirrors `template` (key order + nesting), + * pulling values from `existing` first, then `patch`. Keys absent from both + * are pushed to the `missing` array and skipped. + */ +function mergeOrdered(template, existing, patch, missing, prefix = '') { + const out = {}; + for (const [key, val] of Object.entries(template)) { + const path = prefix ? `${prefix}.${key}` : key; + if (val && typeof val === 'object' && !Array.isArray(val)) { + const merged = mergeOrdered(val, existing, patch, missing, path); + if (Object.keys(merged).length > 0) out[key] = merged; + } else { + const fromExisting = getByPath(existing, path); + if (typeof fromExisting === 'string') { + out[key] = fromExisting; + } else if (Object.prototype.hasOwnProperty.call(patch, path)) { + out[key] = patch[path]; + } else { + missing.push(path); + } + } + } + return out; +} + +function listLocaleFiles() { + return readdirSync(I18N_DIR) + .filter((f) => f.endsWith('.json') && f !== 'en.json') + .sort(); +} + +function emitMissing(en, enKeys) { + if (!existsSync(MISSING_DIR)) mkdirSync(MISSING_DIR, { recursive: true }); + for (const file of listLocaleFiles()) { + const lang = file.replace(/\.json$/, ''); + const localePath = resolve(I18N_DIR, file); + const localeData = readJSON(localePath); + const localeKeys = new Set(collectLeaves(localeData)); + const missing = enKeys.filter((k) => !localeKeys.has(k)); + const dump = {}; + for (const k of missing) dump[k] = getByPath(en, k); + writeJSON(resolve(MISSING_DIR, `${lang}.json`), dump); + console.log(`emit ${file.padEnd(10)} missing=${missing.length}`); + } +} + +function mergeAll(en) { + let failed = false; + for (const file of listLocaleFiles()) { + const lang = file.replace(/\.json$/, ''); + const localePath = resolve(I18N_DIR, file); + const patchPath = resolve(PATCH_DIR, `${lang}.json`); + const localeData = readJSON(localePath); + const patch = existsSync(patchPath) ? readJSON(patchPath) : {}; + + const stillMissing = []; + const merged = mergeOrdered(en, localeData, patch, stillMissing); + writeJSON(localePath, merged); + + if (stillMissing.length > 0) { + failed = true; + console.log( + `FAIL ${file.padEnd(10)} still missing ${stillMissing.length} keys: ${stillMissing + .slice(0, 3) + .join(', ')}${stillMissing.length > 3 ? '…' : ''}` + ); + } else { + console.log(`ok ${file.padEnd(10)} fully merged`); + } + } + if (failed) process.exit(1); +} + +const en = readJSON(resolve(I18N_DIR, 'en.json')); +const enKeys = collectLeaves(en); +console.log(`en.json: ${enKeys.length} leaf keys`); + +if (EMIT_MISSING) emitMissing(en, enKeys); +else mergeAll(en);