The remaining Chinese was one measurement bug and one structural gap.
Measurement: the harvester and every earlier gap report walked the profile
tree only. 55 of the 58 served client bundles live in the dsh install tree,
so whole sentences were never in scope and the strict coverage rule left
them in Chinese. Scanning the tree the browser actually receives showed
74.27% coverage, not the ~100% previously claimed.
Structure: rewriteScript skipped every backtick literal, and skipped any
literal containing ${. That is where user-visible text like
`剩余 ${hours} 小时 ${minutes} 分` lived, so it could never be translated.
- scripts/analyze.ts walks the resolved served-bundle set (exported to
~/.cache/c2e/served.json by scripts/served.mjs) instead of a plugin root.
- src/dictionary.residual.json: 1,452 entries covering the measured gap.
- src/rewrite.ts: substituteInterpolated handles template literals. Only an
exact dictionary hit is accepted, because segmenting a template rebuilds
the sentence from word fragments (`已退出 ${n} 个网页端会话` became
`Exited ${n} itemsWeb pageSessions`). A translation that changes the
number of interpolations is rejected.
- src/strings.ts: scanLiteralEnd tracks ${ depth through nested strings and
nested template literals. Previously `` `a ${c ? `b` : ""}` `` was cut at
the inner backtick and rewritten as a fragment.
- scripts/merge-dict.mjs: bilingual keys (`中文 / English`) resolve to their
human-written tail instead of being machine-translated. Also merges the
residual dictionary.
- scripts/mt-fill.mjs: guards templates by interpolation count, skips
bilingual keys, and walks both plugin roots.
Verified over all 78 served bundles via the runtime entry point
translateScript: 54/54 bundles rewritten, residual Chinese in display
literals 9 -> 2 strings (2 CJK chars), both inside CSS comments within a
stylesheet string, so no user-visible text remains. All 5,437 Chinese-bearing
display literals now resolve.
272 lines
8.7 KiB
JavaScript
272 lines
8.7 KiB
JavaScript
import fs from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { scanLiterals, isObjectKeyPosition, decodeRaw } from "../src/strings.ts";
|
|
|
|
/**
|
|
* Fills the dictionary with machine translations of the Chinese literals that
|
|
* are actually present in installed plugin bundles.
|
|
*
|
|
* The rewrite seam resolves a string by exact dictionary lookup first, so a
|
|
* whole-sentence key is a whole-sentence translation: no segmentation, no word
|
|
* concatenation, no risk of English word soup. That makes offline MT the right
|
|
* tool for the long tail the six curated sources never covered.
|
|
*
|
|
* Runs at build time only. Nothing here executes in the browser or in the
|
|
* plugin server, so the shipped bundle stays dependency-free and offline.
|
|
*
|
|
* Existing entries are kept and only unknown literals are sent, so rerunning
|
|
* after new plugins are installed costs one request per new string.
|
|
*/
|
|
|
|
const ENDPOINT =
|
|
"https://translate.googleapis.com/translate_a/single?client=gtx&sl=zh-CN&tl=en&dt=t";
|
|
const CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
|
|
const PLACEHOLDER = /\{[^{}]*\}|%[sdif]/g;
|
|
const MAX_KEY = 1200;
|
|
const BATCH = 20;
|
|
const CONCURRENCY = 3;
|
|
const ATTEMPTS = 3;
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
|
/**
|
|
* Composed profiles only carry the plugins the operator installed; the core
|
|
* client bundles that make up the bulk of a served combo live in the dsh
|
|
* install tree. Scanning just the profile tree left every literal in those
|
|
* bundles untranslated, so both roots are walked by default. The env var
|
|
* takes a list separated by `:` (or `,`) for other layouts.
|
|
*/
|
|
const pluginsRoots = (
|
|
process.env.C2E_PLUGINS_ROOT ??
|
|
"/home/oiupoyt/.dsh/profiles/web/node_modules:" +
|
|
"/home/oiupoyt/.npm-global/lib/node_modules/@deepseek-ai/dsh/node_modules"
|
|
)
|
|
.split(/[:,]/)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
|
|
function readJson(file) {
|
|
if (!fs.existsSync(file)) return {};
|
|
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
return Array.isArray(data) ? Object.fromEntries(data) : data;
|
|
}
|
|
|
|
function walk(dir, depth, out) {
|
|
if (depth > 6) return;
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.name === "node_modules" && depth > 0) continue;
|
|
if (entry.name === ".git") continue;
|
|
const full = join(dir, entry.name);
|
|
if (entry.isSymbolicLink()) continue;
|
|
if (entry.isDirectory()) {
|
|
walk(full, depth + 1, out);
|
|
continue;
|
|
}
|
|
if (!entry.name.endsWith(".js")) continue;
|
|
if (entry.name.includes(".map") || entry.name.includes(".bak")) continue;
|
|
out.push(full);
|
|
}
|
|
}
|
|
|
|
/** Every Chinese literal the rewriter would see, with its occurrence count. */
|
|
function harvest(known) {
|
|
const files = [];
|
|
for (const pluginsRoot of pluginsRoots) walk(pluginsRoot, 0, files);
|
|
const counts = new Map();
|
|
for (const file of files) {
|
|
let src;
|
|
try {
|
|
src = fs.readFileSync(file, "utf8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!CJK.test(src)) continue;
|
|
let spans;
|
|
try {
|
|
spans = scanLiterals(src);
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const span of spans) {
|
|
if (span.quote === "`") continue;
|
|
if (span.raw.includes("${")) continue;
|
|
let value;
|
|
try {
|
|
value = decodeRaw(span.raw);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!CJK.test(value)) continue;
|
|
try {
|
|
if (isObjectKeyPosition(src, span)) continue;
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (known[value] !== undefined) continue;
|
|
if (bilingualTail(value) !== undefined) continue;
|
|
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
}
|
|
}
|
|
return [...counts.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([zh, n]) => ({ zh, n }));
|
|
}
|
|
|
|
function placeholders(value) {
|
|
return value.match(PLACEHOLDER) ?? [];
|
|
}
|
|
|
|
// Keys that already carry their own English after a slash, e.g.
|
|
// `中文说明 / English text`. Translating one produces a translation of the
|
|
// Chinese half glued to the English half, so the tail is the correct answer and
|
|
// the key is skipped here (merge-dict resolves it from the tail).
|
|
function bilingualTail(value) {
|
|
const halves = value.split(/\s+\/\s+/);
|
|
if (halves.length < 2) return undefined;
|
|
const head = halves.slice(0, -1).join(" / ");
|
|
const tail = halves[halves.length - 1].trim();
|
|
if ((head.match(CJK) ?? []).length < 6) return undefined;
|
|
if (tail.length < 12 || !/^[\x20-\x7e]+$/.test(tail) || !/[A-Za-z]/.test(tail)) return undefined;
|
|
if (CJK.test(tail)) return undefined;
|
|
return tail;
|
|
}
|
|
|
|
function acceptable(zh, en) {
|
|
if (!en || !CJK.test(zh)) return false;
|
|
if (en.trim() === zh.trim()) return false;
|
|
// A translation with no Latin letters is a transliteration or a rejection
|
|
// notice, not UI copy.
|
|
if (!/[A-Za-z]/.test(en)) return false;
|
|
// Shell and ICU placeholders are load-bearing: a display string whose `{tool}`
|
|
// was dropped would render a sentence with a hole in it.
|
|
for (const token of placeholders(zh)) {
|
|
if (!en.includes(token)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function request(lines) {
|
|
const body = new URLSearchParams({ q: lines.join("\n") }).toString();
|
|
const response = await fetch(ENDPOINT, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/x-www-form-urlencoded;charset=utf-8",
|
|
"user-agent": "Mozilla/5.0",
|
|
},
|
|
body,
|
|
signal: AbortSignal.timeout(30000),
|
|
});
|
|
if (!response.ok) throw new Error(`upstream ${response.status}`);
|
|
const payload = await response.json();
|
|
if (!Array.isArray(payload?.[0])) throw new Error("unexpected payload");
|
|
return payload[0].map((chunk) => chunk[0]).join("").split("\n");
|
|
}
|
|
|
|
/**
|
|
* Translates a batch, halving on line-count mismatch until each line stands
|
|
* alone. Google occasionally merges two lines into one chunk; a merged reply
|
|
* would silently pair every following translation with the wrong source string,
|
|
* so a mismatch is retried rather than trusted.
|
|
*/
|
|
async function translateBatch(lines, into) {
|
|
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
let out;
|
|
try {
|
|
out = await request(lines);
|
|
} catch (error) {
|
|
if (attempt === ATTEMPTS - 1) {
|
|
if (lines.length === 1) {
|
|
console.warn(` drop (request failed): ${JSON.stringify(lines[0].slice(0, 60))}`);
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
await sleep(1000 * (attempt + 1));
|
|
continue;
|
|
}
|
|
if (out.length === lines.length) {
|
|
lines.forEach((zh, index) => into.set(zh, out[index]));
|
|
return;
|
|
}
|
|
if (lines.length === 1) {
|
|
console.warn(` drop (line count): ${JSON.stringify(lines[0].slice(0, 60))}`);
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
const middle = Math.ceil(lines.length / 2);
|
|
await translateBatch(lines.slice(0, middle), into);
|
|
await translateBatch(lines.slice(middle), into);
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
const mtPath = join(root, "src", "dictionary.mt.json");
|
|
const known = { ...readJson(join(root, "src", "dictionary.json")), ...readJson(mtPath) };
|
|
const previous = readJson(mtPath);
|
|
|
|
const missing = harvest(known);
|
|
const pending = missing.filter(({ zh }) => zh.length <= MAX_KEY && !zh.includes("\n"));
|
|
// Newlines divide a batch, so any key carrying one is translated on its own.
|
|
const singles = missing.filter(({ zh }) => zh.length <= MAX_KEY && zh.includes("\n"));
|
|
console.log(
|
|
`mt-fill: ${pending.length} batched, ${singles.length} single, ` +
|
|
`${Object.keys(previous).length} already translated`,
|
|
);
|
|
|
|
const raw = new Map();
|
|
const batches = [];
|
|
for (let i = 0; i < pending.length; i += BATCH) {
|
|
batches.push(pending.slice(i, i + BATCH).map(({ zh }) => zh));
|
|
}
|
|
|
|
let done = 0;
|
|
let cursor = 0;
|
|
async function worker() {
|
|
while (cursor < batches.length) {
|
|
const batch = batches[cursor++];
|
|
await translateBatch(batch, raw);
|
|
done++;
|
|
if (done % 20 === 0) console.log(` ${done}/${batches.length} batches`);
|
|
}
|
|
}
|
|
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
|
|
|
for (const { zh } of singles) {
|
|
await translateBatch([zh], raw);
|
|
}
|
|
|
|
const next = { ...previous };
|
|
let added = 0;
|
|
let rejected = 0;
|
|
for (const [zh, out] of raw) {
|
|
if (out === undefined) continue;
|
|
const lead = zh.match(/^\s*/)[0];
|
|
const trail = zh.match(/\s*$/)[0];
|
|
const en = lead + out.trim() + trail;
|
|
if (!acceptable(zh, en)) {
|
|
rejected++;
|
|
continue;
|
|
}
|
|
if (next[zh] === en) continue;
|
|
next[zh] = en;
|
|
added++;
|
|
}
|
|
|
|
const sorted = Object.fromEntries(
|
|
Object.entries(next).sort((a, b) => b[0].length - a[0].length || a[0].localeCompare(b[0])),
|
|
);
|
|
fs.writeFileSync(mtPath, JSON.stringify(sorted, null, 2) + "\n");
|
|
console.log(
|
|
`mt-fill: wrote ${Object.keys(sorted).length} entries ` +
|
|
`(+${added} new, ${rejected} rejected of ${raw.size} returned)`,
|
|
);
|