Files
Spoolman/client_v2/scripts/convert-locales.mjs
Donkie 4bdebbdded Add localization to v2 client (svelte-i18n)
Introduce full i18n for the Svelte client_v2 using svelte-i18n (ICU/formatjs),
mirroring the old client's language support.

- i18n setup in src/lib/i18n/ (lazy per-language loaders, init + waitLocale boot
  guard, setLocale persisted to localStorage, Intl locale helper); 26-language
  registry ported from the old client.
- Language picker on the Settings page.
- scripts/convert-locales.mjs seeds static/locales/<lang>/common.json from the
  old client's Weblate files: {{var}} -> {var}, escapes literal braces, merges
  i18next plural suffixes into ICU plurals. Existing translations are reused via
  the same keys; new v2-only strings are added to en/common.json only and fall
  back to English until Weblate fills them.
- Extracted all hardcoded UI strings across routes and components to $_ lookups;
  DateTimeField derives month/weekday names from Intl in the active locale.
- static/locales is prettier-ignored (mirrors the old client's public/locales).

Follow-up (maintainer): add a Weblate component pointing at
client_v2/static/locales (nested JSON, ICU MessageFormat, English source).

Claude-Session: https://claude.ai/code/session_01Ne7RsfHejKZX97uAq3aVMn
2026-07-13 14:45:37 +02:00

132 lines
5.1 KiB
JavaScript

#!/usr/bin/env node
// One-shot seed tool: convert the old React client's i18next locale files into
// the ICU (svelte-i18n / formatjs) format used by client_v2.
//
// node scripts/convert-locales.mjs
//
// Reads ../client/public/locales/<lang>/common.json (i18next format)
// Writes ./static/locales/<lang>/common.json (ICU/formatjs format)
//
// The transform is intentionally minimal so existing community translations are
// preserved as faithfully as possible — see the localization plan. Three
// transforms are applied to every translatable string leaf:
// 1. {{var}} -> {var} (i18next interpolation -> ICU placeholder)
// 2. lone { or } -> '{' '}' (ICU literal-brace escaping)
// 3. i18next plural-suffix siblings (key_one / key_other / _zero / _two /
// _few / _many) are merged into a single ICU `{count, plural, ...}` key.
//
// After running once, client_v2/static/locales is owned by Weblate. Re-running
// regenerates from the old files, so only do that for an intentional re-seed.
import { readdirSync, readFileSync, mkdirSync, writeFileSync, statSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC_DIR = join(__dirname, '../../client/public/locales');
const OUT_DIR = join(__dirname, '../static/locales');
const PLURAL_CATEGORIES = ['zero', 'one', 'two', 'few', 'many', 'other'];
// Sentinels used to protect real interpolations while literal braces are
// escaped. They are wrapped in NUL/US control chars, which never occur in the
// translation text, so there is no risk of colliding with real content.
const OPEN = 'IPL';
const CLOSE = 'IPR';
/** Convert one i18next string to an ICU/formatjs string. */
function convertString(input) {
// Step A: protect real interpolations {{ident}} so brace-escaping skips them.
const protectedStr = input.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, id) => OPEN + id + CLOSE);
// Step B: escape any remaining literal braces (e.g. the label templateHelp
// text, which documents literal {id}/{} syntax) so ICU treats them as text.
const escaped = protectedStr.replace(/\{/g, "'{'").replace(/\}/g, "'}'");
// Step C: restore interpolations as clean ICU placeholders {ident}.
return escaped.split(OPEN).join('{').split(CLOSE).join('}');
}
/**
* Recursively transform an object. Returns a new object with strings converted
* and i18next plural-suffix sibling keys merged into ICU plural keys.
*/
function transform(obj) {
// Detect plural-suffix groups among this object's own string keys.
const pluralGroups = {}; // base -> { category: value }
for (const key of Object.keys(obj)) {
if (typeof obj[key] !== 'string') continue;
const m = key.match(/^(.*)_([a-z]+)$/);
if (m && PLURAL_CATEGORIES.includes(m[2])) {
(pluralGroups[m[1]] ??= {})[m[2]] = obj[key];
}
}
// Treat a base as a plural set if it carries any of the recognisable plural
// categories. Languages differ: English has one+other, Russian one+few+many
// (no `other`), Chinese/Japanese/Korean only `other`. The only pluralised key
// in the source is `selectedTotal`, so false positives are not a concern.
const activeBases = new Set(
Object.keys(pluralGroups).filter((b) => {
const cats = pluralGroups[b];
return cats.other !== undefined || cats.one !== undefined || Object.keys(cats).length >= 2;
})
);
const out = {};
const emittedBase = new Set();
for (const [key, value] of Object.entries(obj)) {
const m = key.match(/^(.*)_([a-z]+)$/);
if (m && PLURAL_CATEGORIES.includes(m[2]) && activeBases.has(m[1])) {
const base = m[1];
if (emittedBase.has(base)) continue;
emittedBase.add(base);
const branches = { ...pluralGroups[base] };
// formatjs requires an `other` branch. Synthesise one from the most
// general available form when the language doesn't provide it (e.g. ru).
if (branches.other === undefined) {
branches.other = branches.many ?? branches.few ?? branches.one ?? branches.two;
}
const parts = PLURAL_CATEGORIES.filter((c) => branches[c] !== undefined).map(
(c) => `${c} {${convertString(branches[c])}}`
);
out[base] = `{count, plural, ${parts.join(' ')}}`;
continue;
}
if (value && typeof value === 'object') {
out[key] = transform(value);
} else if (typeof value === 'string') {
out[key] = convertString(value);
} else {
out[key] = value;
}
}
return out;
}
function isDir(p) {
try {
return statSync(p).isDirectory();
} catch {
return false;
}
}
const langs = readdirSync(SRC_DIR).filter((name) => isDir(join(SRC_DIR, name)));
let written = 0;
for (const lang of langs) {
const srcFile = join(SRC_DIR, lang, 'common.json');
let raw;
try {
raw = JSON.parse(readFileSync(srcFile, 'utf8'));
} catch (e) {
console.warn(`skip ${lang}: ${e.message}`);
continue;
}
const converted = transform(raw);
const json = JSON.stringify(converted, null, 4) + '\n';
JSON.parse(json); // sanity: valid JSON round-trip
const outFile = join(OUT_DIR, lang, 'common.json');
mkdirSync(dirname(outFile), { recursive: true });
writeFileSync(outFile, json, 'utf8');
written++;
}
console.log(`Converted ${written} locale(s) -> ${OUT_DIR}`);