/**
* One-time migration: fold the existing .xcstrings (Apple) and Compose
* strings.xml (KMP) files into a single strings.json.
*
* - Placeholders are normalized to canonical `{argN}` tokens.
* - KMP `_one` / `_other` key pairs are folded into one plural entry.
* - `targets` is set from which platform(s) a key was found in.
* Arg names (`arg1`, `count`, …) and `context` are best-effort; curate by hand after.
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { APPLE_XCSTRINGS, KMP_RESOURCES, STRINGS_JSON } from "../config";
import { parseAndroid } from "../lib/android-xml";
import { parseXcstrings, type XcLocalization } from "../lib/xcstrings";
import { toCanonical, type ParsedString } from "../lib/placeholders";
import type { Arg, PluralForms, StringEntry, StringsFile, Target } from "../types";
const PLURAL_SUFFIXES = ["zero", "one", "two", "few", "many", "other"] as const;
interface Draft {
targets: Set;
args?: Arg[];
translations?: Record;
plural?: Record;
comment?: string;
}
/** Give a friendlier name to a lone integer arg (common for counts). */
function prettify(parsed: ParsedString): ParsedString {
if (parsed.args.length === 1 && parsed.args[0]!.type === "int") {
return {
text: parsed.text.replaceAll("{arg1}", "{count}"),
args: [{ name: "count", type: "int" }],
};
}
return parsed;
}
function ingest(draft: Draft, lang: string, raw: string) {
const p = prettify(toCanonical(raw));
if (p.args.length) draft.args = p.args;
(draft.translations ??= {})[lang] = p.text;
}
// ---- Apple (.xcstrings) -----------------------------------------------------
function unitValue(loc: XcLocalization | undefined): string | undefined {
return loc?.stringUnit?.value;
}
async function readApple(drafts: Map) {
const cat = parseXcstrings(await Bun.file(APPLE_XCSTRINGS).text());
for (const [key, entry] of Object.entries(cat.strings)) {
const draft = get(drafts, key);
draft.targets.add("apple");
if (entry.comment) draft.comment = entry.comment;
for (const [lang, loc] of Object.entries(entry.localizations ?? {})) {
const plural = loc.variations?.plural;
if (plural) {
for (const [cat, unit] of Object.entries(plural)) {
const p = prettify(toCanonical(unit.stringUnit.value));
if (p.args.length) draft.args = p.args;
((draft.plural ??= {})[lang] ??= { other: "" } as PluralForms)[
cat as keyof PluralForms
] = p.text;
}
} else {
const v = unitValue(loc);
if (v !== undefined) ingest(draft, lang, v);
}
}
}
}
// ---- KMP (compose strings.xml) ---------------------------------------------
function pluralBaseOf(name: string): { base: string; cat: string } | null {
for (const suf of PLURAL_SUFFIXES) {
if (name.endsWith(`_${suf}`)) return { base: name.slice(0, -(suf.length + 1)), cat: suf };
}
return null;
}
async function readKmp(drafts: Map, sourceLanguage: string) {
const dirs = readdirSync(KMP_RESOURCES, { withFileTypes: true })
.filter((d) => d.isDirectory() && (d.name === "values" || d.name.startsWith("values-")))
.map((d) => d.name);
for (const dir of dirs) {
const lang = dir === "values" ? sourceLanguage : dir.slice("values-".length);
const file = join(KMP_RESOURCES, dir, "strings.xml");
if (!(await Bun.file(file).exists())) continue;
const { strings, plurals } = parseAndroid(await Bun.file(file).text());
for (const s of strings) {
const draft = get(drafts, s.name);
draft.targets.add("kmp");
ingest(draft, lang, s.value);
}
// Real blocks (if any) fold directly into the base key.
for (const p of plurals) {
const draft = get(drafts, p.name);
draft.targets.add("kmp");
for (const [cat, raw] of Object.entries(p.items)) {
const parsed = prettify(toCanonical(raw));
if (parsed.args.length) draft.args = parsed.args;
((draft.plural ??= {})[lang] ??= { other: "" } as PluralForms)[
cat as keyof PluralForms
] = parsed.text;
}
}
}
}
/**
* Global fold: collapse flat `_` sibling keys (e.g. `_one`/`_other`)
* into one plural entry on ``, regardless of which platform they came from.
* Requires `other` plus at least one more category to avoid folding non-plurals.
*/
function foldPlurals(drafts: Map) {
const groups = new Map();
for (const key of drafts.keys()) {
const pl = pluralBaseOf(key);
if (pl && drafts.get(key)!.translations) {
(groups.get(pl.base) ?? groups.set(pl.base, []).get(pl.base)!).push(key);
}
}
for (const [base, keys] of groups) {
const cats = new Set(keys.map((k) => pluralBaseOf(k)!.cat));
if (!cats.has("other") || cats.size < 2) continue;
const target = get(drafts, base);
for (const key of keys) {
const cat = pluralBaseOf(key)!.cat as keyof PluralForms;
const src = drafts.get(key)!;
src.targets.forEach((t) => target.targets.add(t));
if (src.args?.length) target.args = src.args;
target.comment ??= src.comment;
for (const [lang, text] of Object.entries(src.translations ?? {})) {
((target.plural ??= {})[lang] ??= { other: "" } as PluralForms)[cat] = text;
}
drafts.delete(key);
}
}
}
// ---- assembly ---------------------------------------------------------------
function get(drafts: Map, key: string): Draft {
let d = drafts.get(key);
if (!d) drafts.set(key, (d = { targets: new Set() }));
return d;
}
export async function migrate(sourceLanguage = "en") {
const drafts = new Map();
await readApple(drafts);
await readKmp(drafts, sourceLanguage);
foldPlurals(drafts);
const langs = new Set([sourceLanguage]);
const strings: Record = {};
for (const key of [...drafts.keys()].sort()) {
const d = drafts.get(key)!;
const entry: StringEntry = { context: d.comment ?? "TODO: describe where this appears." };
const targets = [...d.targets].sort() as Target[];
if (targets.length && targets.length < 2) entry.targets = targets;
if (d.args?.length) entry.args = d.args;
if (d.plural) {
entry.plural = d.plural;
Object.keys(d.plural).forEach((l) => langs.add(l));
} else if (d.translations) {
entry.translations = d.translations;
Object.keys(d.translations).forEach((l) => langs.add(l));
}
strings[key] = entry;
}
const out: StringsFile = {
sourceLanguage,
supportedLanguages: [...langs].sort(),
strings,
};
await Bun.write(
STRINGS_JSON,
JSON.stringify({ $schema: "./schema.json", ...out }, null, 2) + "\n",
);
console.log(
`Wrote ${STRINGS_JSON}\n ${Object.keys(strings).length} keys, languages: ${[...langs].sort().join(", ")}`,
);
}