234 lines
8.1 KiB
JavaScript
234 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Bulk-translate missing locale keys via Lingva (public Google-Translate
|
|
// proxy, no auth required). Pairs with `scripts/check-locales.mjs`:
|
|
// check-locales detects the gap, this script fills it.
|
|
//
|
|
// node scripts/bulk_translate.mjs
|
|
// node scripts/check-locales.mjs # verify
|
|
//
|
|
// Idempotent — only translates keys missing in each locale, AND
|
|
// retries any "suspicious" keys where the stored translation equals
|
|
// the English source (Lingva passing strings through unchanged).
|
|
//
|
|
// Design notes:
|
|
// - Sentinels: `{{placeholder}}` tokens are frozen as private-use
|
|
// unicode chars (U+E000 + index). MT engines pass them through
|
|
// unchanged because no language model maps them, surviving any
|
|
// word reordering the engine performs.
|
|
// - Checkpointing: writes every 50 keys so a network blip loses at
|
|
// most the last batch instead of the whole locale.
|
|
// - No English fallback on failure: failed keys are left absent so
|
|
// a re-run picks them up (no silent English bleed).
|
|
// - `LINGVA_HOST=https://lingva.example.com node scripts/bulk_translate.mjs`
|
|
// lets you point at a self-hosted mirror if the public one goes
|
|
// down. (Lingva.ml has occasional 502s; the script retries with
|
|
// exponential backoff.)
|
|
|
|
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const LOCALES_DIR = join(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
'frontend',
|
|
'static',
|
|
'locales'
|
|
);
|
|
const LINGVA = process.env.LINGVA_HOST || 'https://lingva.ml';
|
|
const THROTTLE_MS = 150;
|
|
const RETRIES = 4;
|
|
const RETRY_BACKOFF_MS = 800;
|
|
|
|
const LOCALE_TO_LINGVA = {
|
|
ar: 'ar',
|
|
de: 'de',
|
|
es: 'es',
|
|
fa: 'fa',
|
|
fr: 'fr',
|
|
hi: 'hi',
|
|
it: 'it',
|
|
ja: 'ja',
|
|
ko: 'ko',
|
|
nl: 'nl',
|
|
pl: 'pl',
|
|
pt: 'pt',
|
|
ru: 'ru',
|
|
zh: 'zh',
|
|
'zh-TW': 'zh_HANT'
|
|
};
|
|
const SKIP = new Set(['en']);
|
|
|
|
function flat(obj, prefix = '', out = {}) {
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
const key = prefix ? `${prefix}.${k}` : k;
|
|
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out);
|
|
else out[key] = v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function setNested(obj, dottedKey, value) {
|
|
const parts = dottedKey.split('.');
|
|
let cur = obj;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const k = parts[i];
|
|
if (cur[k] === undefined || typeof cur[k] !== 'object' || Array.isArray(cur[k])) {
|
|
cur[k] = {};
|
|
}
|
|
cur = cur[k];
|
|
}
|
|
cur[parts[parts.length - 1]] = value;
|
|
}
|
|
|
|
// Private-use unicode sentinels: U+E000 + index encoded as code point.
|
|
// MT engines pass them through unchanged because they have no glyphs and
|
|
// no language model maps them. Each placeholder gets its own char so
|
|
// reordering still works (we restore by char, not by position).
|
|
function freezePlaceholders(text) {
|
|
const placeholders = [];
|
|
const frozen = text.replace(/\{\{([^}]+)\}\}/g, (_, name) => {
|
|
const idx = placeholders.length;
|
|
placeholders.push(name);
|
|
return String.fromCodePoint(0xe000 + idx);
|
|
});
|
|
return { frozen, placeholders };
|
|
}
|
|
function thawPlaceholders(text, placeholders) {
|
|
// Iterate over code points so multi-byte chars match correctly.
|
|
let out = '';
|
|
for (const ch of text) {
|
|
const cp = ch.codePointAt(0);
|
|
if (cp >= 0xe000 && cp < 0xe000 + placeholders.length) {
|
|
out += `{{${placeholders[cp - 0xe000]}}}`;
|
|
} else {
|
|
out += ch;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
async function translate(text, targetLang) {
|
|
const { frozen, placeholders } = freezePlaceholders(text);
|
|
const url = `${LINGVA}/api/v1/en/${targetLang}/${encodeURIComponent(frozen)}`;
|
|
let lastErr;
|
|
for (let attempt = 0; attempt < RETRIES; attempt++) {
|
|
try {
|
|
const res = await fetch(url, {
|
|
headers: { 'User-Agent': 'oxicloud-i18n-fill' }
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const json = await res.json();
|
|
const out = json.translation;
|
|
if (!out || typeof out !== 'string') throw new Error('no translation field');
|
|
return thawPlaceholders(out, placeholders);
|
|
} catch (e) {
|
|
lastErr = e;
|
|
await sleep(RETRY_BACKOFF_MS * (attempt + 1));
|
|
}
|
|
}
|
|
throw lastErr;
|
|
}
|
|
|
|
// Heuristic: a translation looks suspicious if it equals the source text
|
|
// AND the source has at least one letter. Single-word brand terms
|
|
// ("OxiCloud") are excluded — those legitimately stay identical.
|
|
function looksUntranslated(src, translated) {
|
|
if (typeof src !== 'string' || typeof translated !== 'string') return false;
|
|
if (src !== translated) return false;
|
|
if (!/[a-zA-Z]/.test(src)) return false;
|
|
// Single CamelCase / lowercase token without spaces — likely a brand.
|
|
if (!/\s/.test(src)) return false;
|
|
return true;
|
|
}
|
|
|
|
const en = flat(JSON.parse(readFileSync(join(LOCALES_DIR, 'en.json'), 'utf8')));
|
|
const enKeys = Object.keys(en);
|
|
|
|
const localeFiles = readdirSync(LOCALES_DIR)
|
|
.filter((f) => f.endsWith('.json'))
|
|
.map((f) => f.slice(0, -5))
|
|
.filter((l) => !SKIP.has(l));
|
|
|
|
const failures = {}; // locale -> array of {key, src, err}
|
|
|
|
for (const locale of localeFiles) {
|
|
const lingvaCode = LOCALE_TO_LINGVA[locale];
|
|
if (!lingvaCode) {
|
|
console.error(`! no Lingva mapping for ${locale}, skipping`);
|
|
continue;
|
|
}
|
|
const path = join(LOCALES_DIR, `${locale}.json`);
|
|
const dict = JSON.parse(readFileSync(path, 'utf8'));
|
|
const dictFlat = flat(dict);
|
|
|
|
// Phase A: fill missing keys.
|
|
const missing = enKeys.filter((k) => !(k in dictFlat));
|
|
// Phase B: retry suspicious (en === locale) translations.
|
|
const suspicious = enKeys.filter((k) => k in dictFlat && looksUntranslated(en[k], dictFlat[k]));
|
|
|
|
const todo = [...missing, ...suspicious];
|
|
if (todo.length === 0) {
|
|
console.log(`✓ ${locale}: clean (missing 0, suspicious 0)`);
|
|
continue;
|
|
}
|
|
console.log(
|
|
`▶ ${locale}: ${missing.length} missing + ${suspicious.length} suspicious via Lingva (${lingvaCode})`
|
|
);
|
|
|
|
failures[locale] = [];
|
|
let done = 0;
|
|
let writeCheckpoint = 0;
|
|
for (const k of todo) {
|
|
const src = en[k];
|
|
if (typeof src !== 'string') {
|
|
done++;
|
|
continue;
|
|
}
|
|
try {
|
|
const translated = await translate(src, lingvaCode);
|
|
// Re-check: did Lingva just hand back the English unchanged?
|
|
if (looksUntranslated(src, translated)) {
|
|
failures[locale].push({ key: k, src, err: 'identical_to_source' });
|
|
}
|
|
setNested(dict, k, translated);
|
|
done++;
|
|
} catch (e) {
|
|
failures[locale].push({ key: k, src, err: e.message });
|
|
// Don't write English fallback — leave the key absent so the
|
|
// next run picks it up. (For suspicious-retries, the existing
|
|
// suspect value stays in place.)
|
|
done++;
|
|
}
|
|
|
|
// Persist progress every 50 keys so a network blip doesn't lose
|
|
// everything translated so far.
|
|
if (done - writeCheckpoint >= 50) {
|
|
writeFileSync(path, JSON.stringify(dict, null, 2) + '\n');
|
|
writeCheckpoint = done;
|
|
console.log(` ${locale}: ${done}/${todo.length} (checkpoint)`);
|
|
}
|
|
await sleep(THROTTLE_MS);
|
|
}
|
|
writeFileSync(path, JSON.stringify(dict, null, 2) + '\n');
|
|
console.log(
|
|
`✓ ${locale}: ${done}/${todo.length} processed, ${failures[locale].length} unresolved`
|
|
);
|
|
}
|
|
|
|
const totalUnresolved = Object.values(failures).reduce((n, arr) => n + arr.length, 0);
|
|
if (totalUnresolved > 0) {
|
|
console.log(`\nUnresolved: ${totalUnresolved}`);
|
|
for (const [loc, arr] of Object.entries(failures)) {
|
|
if (arr.length === 0) continue;
|
|
console.log(` ${loc}: ${arr.length}`);
|
|
for (const f of arr.slice(0, 5)) {
|
|
console.log(` ${f.key} (${f.err}): "${f.src.slice(0, 50)}"`);
|
|
}
|
|
if (arr.length > 5) console.log(` … +${arr.length - 5} more`);
|
|
}
|
|
}
|
|
console.log('\nDone.');
|