mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-15 19:32:20 +03:00
feat(i18n): run-translation --adopt re-bootstraps .i18n-state.json without retranslating
This commit is contained in:
5982
.i18n-state.json
Normal file
5982
.i18n-state.json
Normal file
File diff suppressed because it is too large
Load Diff
30
scripts/i18n/lib/translation-state.mjs
Normal file
30
scripts/i18n/lib/translation-state.mjs
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
129
tests/unit/i18n-translation-state-adopt.test.ts
Normal file
129
tests/unit/i18n-translation-state-adopt.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* `adoptState` (scripts/i18n/lib/translation-state.mjs) rebuilds the
|
||||
* `.i18n-state.json` document from what is already on disk — hashing sources
|
||||
* and existing mirrors, never calling a translation backend — so incremental
|
||||
* drift detection (`npm run i18n:check`) can be re-bootstrapped after the state
|
||||
* file was lost. `run-translation.mjs --adopt` is a thin wrapper around it.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { adoptState } from "../../scripts/i18n/lib/translation-state.mjs";
|
||||
|
||||
type LocaleState = { source_hash: string; target_hash: string; updated_at: string };
|
||||
type AdoptedState = {
|
||||
sources: Record<string, { source_hash: string; locales: Record<string, LocaleState> }>;
|
||||
};
|
||||
|
||||
const RUN_TRANSLATION = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../scripts/i18n/run-translation.mjs"
|
||||
);
|
||||
|
||||
const sha = (s: string) => createHash("sha256").update(s).digest("hex");
|
||||
|
||||
async function withTempRoot(fn: (root: string) => Promise<void>): Promise<void> {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "i18n-adopt-"));
|
||||
try {
|
||||
await fn(root);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const mirrorPathFor = (root: string) => (rel: string, locale: string) =>
|
||||
path.join(root, "docs", "i18n", locale, rel);
|
||||
|
||||
test("adoptState hashes every existing source/target pair and skips missing targets", async () => {
|
||||
await withTempRoot(async (root) => {
|
||||
writeFileSync(path.join(root, "README.md"), "# A\n");
|
||||
mkdirSync(path.join(root, "docs", "i18n", "es"), { recursive: true });
|
||||
writeFileSync(path.join(root, "docs", "i18n", "es", "README.md"), "# A (Español)\n");
|
||||
|
||||
const state = (await adoptState({
|
||||
root,
|
||||
sources: ["README.md"],
|
||||
locales: ["es", "de"],
|
||||
targetPathFor: mirrorPathFor(root),
|
||||
})) as AdoptedState;
|
||||
|
||||
assert.equal(state.sources["README.md"].source_hash, sha("# A\n"));
|
||||
assert.equal(state.sources["README.md"].locales.es.target_hash, sha("# A (Español)\n"));
|
||||
assert.equal(state.sources["README.md"].locales.es.source_hash, sha("# A\n"));
|
||||
assert.equal(state.sources["README.md"].locales.de, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("adoptState records every source (nested paths too), keeps `locales` empty without mirrors, and stamps ISO timestamps", async () => {
|
||||
await withTempRoot(async (root) => {
|
||||
mkdirSync(path.join(root, "docs", "guides"), { recursive: true });
|
||||
writeFileSync(path.join(root, "README.md"), "# A\n");
|
||||
writeFileSync(path.join(root, "docs", "guides", "GUIDE.md"), "# Guide\n");
|
||||
mkdirSync(path.join(root, "docs", "i18n", "fr", "docs", "guides"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(root, "docs", "i18n", "fr", "docs", "guides", "GUIDE.md"),
|
||||
"# Guide (FR)\n"
|
||||
);
|
||||
|
||||
const asked: string[] = [];
|
||||
const before = Date.now();
|
||||
const state = (await adoptState({
|
||||
root,
|
||||
sources: ["README.md", "docs/guides/GUIDE.md"],
|
||||
locales: ["fr", "de"],
|
||||
targetPathFor: (rel: string, locale: string) => {
|
||||
asked.push(`${rel} → ${locale}`);
|
||||
return mirrorPathFor(root)(rel, locale);
|
||||
},
|
||||
})) as AdoptedState;
|
||||
|
||||
assert.deepEqual(Object.keys(state.sources), ["README.md", "docs/guides/GUIDE.md"]);
|
||||
// A source without any mirror on disk is still recorded, with nothing adopted.
|
||||
assert.deepEqual(state.sources["README.md"], { source_hash: sha("# A\n"), locales: {} });
|
||||
|
||||
const guide = state.sources["docs/guides/GUIDE.md"];
|
||||
assert.equal(guide.source_hash, sha("# Guide\n"));
|
||||
assert.deepEqual(Object.keys(guide.locales), ["fr"]);
|
||||
assert.equal(guide.locales.fr.source_hash, sha("# Guide\n"));
|
||||
assert.equal(guide.locales.fr.target_hash, sha("# Guide (FR)\n"));
|
||||
|
||||
const stamp = Date.parse(guide.locales.fr.updated_at);
|
||||
assert.equal(new Date(stamp).toISOString(), guide.locales.fr.updated_at);
|
||||
assert.ok(stamp >= before && stamp <= Date.now(), "updated_at is the adoption time");
|
||||
|
||||
// Every (source, locale) pair is resolved through the caller's path mapper.
|
||||
assert.deepEqual(asked, [
|
||||
"README.md → fr",
|
||||
"README.md → de",
|
||||
"docs/guides/GUIDE.md → fr",
|
||||
"docs/guides/GUIDE.md → de",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("adoptState rejects instead of silently skipping a listed source that is missing on disk", async () => {
|
||||
await withTempRoot(async (root) => {
|
||||
await assert.rejects(
|
||||
adoptState({
|
||||
root,
|
||||
sources: ["MISSING.md"],
|
||||
locales: ["es"],
|
||||
targetPathFor: mirrorPathFor(root),
|
||||
}),
|
||||
{ code: "ENOENT" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("run-translation.mjs --help advertises --adopt as a no-API-call state rebuild", () => {
|
||||
const out = execFileSync(process.execPath, [RUN_TRANSLATION, "--help"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
assert.match(out, /--adopt\s+Rebuild \.i18n-state\.json from the files on disk \(no API calls\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user