feat(i18n): run-translation --adopt re-bootstraps .i18n-state.json without retranslating

This commit is contained in:
Markus Hartung
2026-09-02 07:46:46 -03:00
parent b84e47cabf
commit ebc6f44e85
4 changed files with 6160 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import { promises as fs, existsSync } from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
/**
* Builds a `.i18n-state.json` document from what is on disk, without any
* translation call. Used to re-bootstrap incremental drift detection after the
* state file was lost (deleted in v3.8.10) — every existing mirror is adopted
* as "in sync with the current source".
*/
export async function adoptState({ root, sources, locales, targetPathFor }) {
const state = { sources: {} };
for (const rel of sources) {
const sourceHash = sha256(await fs.readFile(path.join(root, rel)));
const entry = { source_hash: sourceHash, locales: {} };
for (const locale of locales) {
const target = targetPathFor(rel, locale);
if (!existsSync(target)) continue;
entry.locales[locale] = {
source_hash: sourceHash,
target_hash: sha256(await fs.readFile(target)),
updated_at: new Date().toISOString(),
};
}
state.sources[rel] = entry;
}
return state;
}

View File

@@ -21,6 +21,7 @@
* npm run i18n:run -- --files=CLAUDE.md,docs/ARCHITECTURE.md
* npm run i18n:run -- --force
* npm run i18n:run:dry
* npm run i18n:run -- --adopt (rebuild .i18n-state.json from disk, no API calls)
*
* Backend (configured via env, never committed):
* OMNIROUTE_TRANSLATION_API_URL e.g. https://cloud.omniroute.dev/v1
@@ -37,6 +38,7 @@ import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { normalizeLocaleText } from "./glossary-normalize.mjs";
import { buildMirrorBar } from "./lib/language-bar.mjs";
import { adoptState } from "./lib/translation-state.mjs";
// ----- .env loader --------------------------------------------------------
// Loads variables from a local `.env` (gitignored) into process.env without
@@ -158,11 +160,13 @@ function parseArgs(argv) {
files: null,
force: false,
dryRun: false,
adopt: false,
concurrency: null,
};
for (const arg of argv.slice(2)) {
if (arg === "--force") opts.force = true;
else if (arg === "--dry-run" || arg === "--dryrun") opts.dryRun = true;
else if (arg === "--adopt") opts.adopt = true;
else if (arg.startsWith("--locale="))
opts.locales = arg
.slice(9)
@@ -191,6 +195,7 @@ function parseArgs(argv) {
" --files=<csv> Relative paths to translate (default: all sources)",
" --force Retranslate even when hashes match",
" --dry-run Report what would happen but never call the API",
" --adopt Rebuild .i18n-state.json from the files on disk (no API calls)",
" --concurrency=<n> Parallel API requests (default: env CONCURRENCY or 4)",
].join("\n")
);
@@ -472,6 +477,20 @@ async function main() {
logInfo(`locales: ${targetLocales.length} (${targetLocales.join(", ")})`);
logInfo(`dry-run: ${opts.dryRun ? "yes" : "no"}, force: ${opts.force ? "yes" : "no"}`);
if (opts.adopt) {
const adopted = await adoptState({
root: ROOT,
sources,
locales: targetLocales,
targetPathFor: (rel, locale) => targetPathFor(rel, locale),
});
await saveState(adopted);
logInfo(
`adopted ${sources.length} sources × ${targetLocales.length} locales into ${path.relative(ROOT, STATE_PATH)}`
);
return;
}
// Read backend env up front so dry-run can still print masked summary.
let backend = null;
if (!opts.dryRun) {