Files
readest/apps/readest-app/scripts/build-wordlens-data.mjs
T
Huang Xin c2ac207945 refactor(wordlens): rename "Word Wise" to "Word Lens" (#4633)
"Word Wise" is a Kindle trademark, so rename the inline-gloss feature to
"Word Lens" throughout the product.

- User-facing strings → "Word Lens" across all 34 locales; brand translated
  for Chinese (zh-CN 单词透镜, zh-TW 單詞透鏡) and German (Word-Lens-Daten).
- Code identifiers: WordWise→WordLens, wordWise→wordLens, WORD_WISE→WORD_LENS.
- Files/dirs: src/services/wordwise→wordlens, WordWisePanel→WordLensPanel,
  wordwise{Ruby,Section}.ts, build/sync scripts, test dirs/fixtures,
  data/wordwise→data/wordlens.
- Storage paths: CDN base, R2 key, on-device cache dir, WORDLENS_R2_BUCKET env,
  pnpm wordlens:{manifest,sync}. manifest.json is path-agnostic so its
  sha256/bytes stay valid (verified).
- biome.json: point the formatter-ignore at data/wordlens so the generated
  one-line gloss packs aren't pretty-printed on commit.

Migration notes:
- Re-run `pnpm wordlens:sync` to upload packs to cdn.readest.com/wordlens/.
- Persisted view-settings keys renamed (wordWiseEnabled/Level/HintLang and
  wordWiseAutoDownload) — saved values reset to defaults once on upgrade.
- Cached packs under the old Data/wordwise/ orphan (harmless re-download).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:09:29 +02:00

614 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Build trimmed Word Lens gloss indices from open datasets.
//
// node scripts/build-wordlens-data.mjs en-zh path/to/ecdict.csv [topN]
// node scripts/build-wordlens-data.mjs zh-en path/to/cedict.txt path/to/hsk.json [topN]
// node scripts/build-wordlens-data.mjs build <src> <tgt> <freq.txt> <gloss.jsonl> [topN]
//
// The generalized `build` mode assembles a pack for any (src→tgt) pair where one
// side is English, from two open datasets:
// - FrequencyWords (CC-BY-SA-4.0): `word count` per line, descending → rank.
// - kaikki Wiktionary extract (CC-BY-SA-4.0): JSONL, used for the gloss map.
// tgt === 'en' → foreign headword → English glosses (extractXToEn).
// src === 'en' → English headword → target-language words (extractEnToX).
//
// Outputs data/wordlens/<pair>.json in the GlossIndexData shape:
// { meta, entries: { word: { r, g } }, inflections: { form: lemma } }
// plus data/wordlens/manifest.json indexing the available packs.
//
// ECDICT (MIT): columns word,phonetic,definition,translation,pos,collins,
// oxford,tag,bnc,frq,exchange,detail,audio. We keep word, frq (rank),
// a short translation (gloss), and parse `exchange` into an inflection map.
// CC-CEDICT (CC-BY-SA): lines `trad simp [pinyin] /sense/sense/`. HSK json
// gives difficulty; higher HSK level => higher (rarer) rank.
import {
readFileSync,
writeFileSync,
mkdirSync,
readdirSync,
createReadStream,
existsSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { createInterface } from 'node:readline';
import { execFileSync } from 'node:child_process';
const OUT_DIR = resolve('data/wordlens');
const TOP_DEFAULT = 30000;
// Keep a hint short + clean: drop bracket annotations ([医], [网络], [ge4]),
// leading part-of-speech tags (ECDICT "a." / "vt." / "n."), and CC-CEDICT
// classifier clauses (CL:...); then keep the first 12 senses.
export function shortGloss(s) {
return s
.split(/[;/]/)
.map((x) =>
x
.replace(/\[[^\]]*\]/g, '') // [医] [网络] [ge4] etc.
.replace(/^\s*(?:[a-zA-Z]{1,5}\.\s*)+/, '') // leading POS: "a. " "vt. "
.replace(/\bCL:[^;/]*/g, '') // CC-CEDICT classifier clause
.replace(/\s+/g, ' ')
.trim(),
)
.filter(Boolean)
.slice(0, 2)
.join('')
.slice(0, 24);
}
// Minimal CSV line parser (ECDICT quotes fields containing commas/newlines).
export function parseCsvLine(line) {
if (line == null) return null;
const out = [];
let cur = '',
inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQ) {
if (ch === '"' && line[i + 1] === '"') {
cur += '"';
i++;
} else if (ch === '"') inQ = false;
else cur += ch;
} else if (ch === '"') inQ = true;
else if (ch === ',') {
out.push(cur);
cur = '';
} else cur += ch;
}
out.push(cur);
return out;
}
// ECDICT exchange tags: 0 lemma, 1 lemma-type, p past, d done, i ing, 3 3rd,
// r comparative, t superlative, s plural. Collect inflected forms (not the lemma).
export function parseExchange(exchange, word) {
const forms = new Set();
for (const part of exchange.split('/')) {
const [tag, val] = part.split(':');
if (!val) continue;
if (['p', 'd', 'i', '3', 'r', 't', 's'].includes(tag)) forms.add(val);
}
forms.delete(word);
return [...forms];
}
// Build the EN→中文 index from ECDICT CSV *text* (so it's unit-testable).
export function buildEnZh(csvText, topN) {
const rows = csvText.split('\n');
const header = parseCsvLine(rows[0]) ?? [];
const col = (name) => header.indexOf(name);
const iWord = col('word'),
iTr = col('translation'),
iFrq = col('frq'),
iEx = col('exchange');
const entries = {};
const inflections = {};
const parsed = [];
for (let i = 1; i < rows.length; i++) {
const c = parseCsvLine(rows[i]);
if (!c || !c[iWord]) continue;
const frq = parseInt(c[iFrq] || '0', 10) || Number.MAX_SAFE_INTEGER;
const g = shortGloss((c[iTr] || '').replace(/\\n/g, ''));
if (!g) continue;
parsed.push({ word: c[iWord], frq, g, exchange: c[iEx] || '' });
}
parsed.sort((a, b) => a.frq - b.frq);
for (const e of parsed.slice(0, topN)) {
entries[e.word.toLowerCase()] = { r: e.frq, g: e.g };
// exchange: "p:past/i:ing/3:thirdperson/..." — map every form back to the lemma.
// `parsed` is sorted most-common-first, so the FIRST lemma to claim a surface
// form wins: ambiguous forms resolve to the most frequent lemma (e.g. "does"
// -> "do", not "doe"; "putting" -> "put", not "putt").
for (const form of parseExchange(e.exchange, e.word)) {
const key = form.toLowerCase();
if (!inflections[key]) inflections[key] = e.word.toLowerCase();
}
}
// Drop standalone inflected-form entries (e.g. "kept", "children") when their
// lemma is also present: at lookup the surface form resolves to the lemma via
// `inflections`, so it inherits the lemma's difficulty rank + real gloss instead
// of being judged on its own + showing a cross-reference ("keep的过去式…").
for (const [form, lemma] of Object.entries(inflections)) {
if (entries[form] && entries[lemma]) delete entries[form];
}
return {
meta: {
source: 'en',
target: 'zh',
metric: 'frq',
version: 1,
count: Object.keys(entries).length,
},
entries,
inflections,
};
}
// Parse one CC-CEDICT line: `傳統 传统 [chuan2 tong3] /tradition/traditional/`.
// Returns { simp, senses: [...] } or null for comments / malformed lines.
export function parseCedictLine(line) {
if (!line || line.startsWith('#')) return null;
const space = line.indexOf(' ');
if (space === -1) return null;
const rest = line.slice(space + 1);
const space2 = rest.indexOf(' ');
if (space2 === -1) return null;
const simp = rest.slice(0, space2);
if (!simp) return null;
const firstSlash = line.indexOf('/');
const lastSlash = line.lastIndexOf('/');
if (firstSlash === -1 || lastSlash <= firstSlash) return null;
const senses = line
.slice(firstSlash + 1, lastSlash)
.split('/')
.map((x) => x.trim())
.filter(Boolean);
if (!senses.length) return null;
return { simp, senses };
}
// HSK json -> Map(word -> level). Tolerates either { "传统": 4 } or
// [{ "hanzi": "传统", "level": 4 }] shapes.
function buildHskLevels(hskJson) {
const levels = new Map();
if (Array.isArray(hskJson)) {
for (const item of hskJson) {
if (!item) continue;
const word = item.hanzi ?? item.simplified ?? item.word;
const level = Number(item.level ?? item.HSK ?? item.hsk);
if (word && Number.isFinite(level)) levels.set(word, level);
}
} else if (hskJson && typeof hskJson === 'object') {
for (const [word, level] of Object.entries(hskJson)) {
const n = Number(level);
if (Number.isFinite(n)) levels.set(word, n);
}
}
return levels;
}
// Build the 中文→EN index from CC-CEDICT *text* + an HSK json object.
// Rank is derived from HSK level (higher level => rarer => higher rank); words
// absent from HSK fall back to a constant "advanced" rank. No inflections.
export function buildZhEn(cedictText, hskJson, topN) {
const levels = buildHskLevels(hskJson);
const rankForLevel = (level) => {
if (!Number.isFinite(level) || level <= 0) return 20000;
return Math.min(level, 9) * 3000;
};
const entries = {};
const seen = new Set();
const parsed = [];
for (const line of cedictText.split('\n')) {
const row = parseCedictLine(line.trim());
if (!row) continue;
// First simplified headword wins (CC-CEDICT lists variants on later lines).
if (seen.has(row.simp)) continue;
seen.add(row.simp);
const g = shortGloss(row.senses.join('/'));
if (!g) continue;
const level = levels.get(row.simp);
parsed.push({ word: row.simp, rank: rankForLevel(level), g });
}
// Lower rank (more common) first so topN keeps the most useful entries.
parsed.sort((a, b) => a.rank - b.rank);
for (const e of parsed.slice(0, topN)) {
entries[e.word] = { r: e.rank, g: e.g };
}
return {
meta: {
source: 'zh',
target: 'en',
metric: 'hsk',
version: 1,
count: Object.keys(entries).length,
},
entries,
inflections: {},
};
}
// ---------------------------------------------------------------------------
// Generalized (frequency + Wiktionary) pack generation for any (src→tgt) pair.
// ---------------------------------------------------------------------------
// FrequencyWords text → [{ word, rank }]. Each non-blank line is `word count`;
// we keep the token before the first space (lowercased + trimmed); rank is the
// running 1-based index of kept lines (= difficulty rank, most common first).
export function parseFrequencyWords(text) {
const out = [];
let rank = 0;
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const word = trimmed.split(' ')[0].toLowerCase().trim();
if (!word) continue;
out.push({ word, rank: ++rank });
}
return out;
}
// Merge a foreign-headword entry's English glosses into the accumulator map.
// `glossMap` is Map(headword -> string[]); senses recur per POS so we merge
// and dedupe, capping at 4 glosses per headword. Returns nothing (mutates map).
function mergeXToEnEntry(obj, sourceCode, glossMap) {
if (!obj || !obj.word) return;
if (obj.lang_code && obj.lang_code !== sourceCode) return;
const senses = Array.isArray(obj.senses) ? obj.senses : [];
const glosses = [];
for (const sense of senses) {
const g = sense?.glosses?.[0];
if (typeof g === 'string' && g.trim()) glosses.push(g.trim());
if (glosses.length >= 4) break;
}
if (!glosses.length) return;
const key = String(obj.word).toLowerCase().trim();
if (!key) return;
const existing = glossMap.get(key) ?? [];
for (const g of glosses) {
if (existing.length >= 4) break;
if (!existing.includes(g)) existing.push(g);
}
glossMap.set(key, existing);
}
// X→en gloss map from in-memory JSONL text (used by tests). headword → glosses.
export function extractXToEn(jsonlText, sourceCode) {
const glossMap = new Map();
for (const line of jsonlText.split('\n')) {
if (!line.trim()) continue;
let obj;
try {
obj = JSON.parse(line);
} catch {
continue;
}
mergeXToEnEntry(obj, sourceCode, glossMap);
}
return glossMap;
}
// Merge an English-headword entry's target-language translations into the map.
// Gathers sense-level translations then top-level ones; keeps t.code === target
// with a t.word; value = `${word} (${roman})` when a roman field is present.
function mergeEnToXEntry(obj, targetCode, glossMap) {
if (!obj || !obj.word) return;
if (obj.lang_code !== 'en') return;
const collected = [];
const consider = (t) => {
if (!t || t.code !== targetCode || !t.word) return;
const word = String(t.word).trim();
if (!word) return;
const value = t.roman ? `${word} (${String(t.roman).trim()})` : word;
if (!collected.includes(value)) collected.push(value);
};
const senses = Array.isArray(obj.senses) ? obj.senses : [];
for (const sense of senses) {
for (const t of sense?.translations ?? []) consider(t);
}
for (const t of obj.translations ?? []) consider(t);
if (!collected.length) return;
const key = String(obj.word).toLowerCase().trim();
if (!key) return;
const existing = glossMap.get(key) ?? [];
for (const v of collected) {
if (existing.length >= 4) break;
if (!existing.includes(v)) existing.push(v);
}
glossMap.set(key, existing);
}
// en→X gloss map from in-memory JSONL text (used by tests). headword → words.
export function extractEnToX(jsonlText, targetCode) {
const glossMap = new Map();
for (const line of jsonlText.split('\n')) {
if (!line.trim()) continue;
let obj;
try {
obj = JSON.parse(line);
} catch {
continue;
}
mergeEnToXEntry(obj, targetCode, glossMap);
}
return glossMap;
}
// WikDict (DBnary/Wiktionary, CC-BY-SA-3.0): rows from the `simple_translation`
// table (written_rep, trans_list). trans_list is ` | `-joined, best-first.
// Returns Map(headword-lowercased -> string[] senses), merged + deduped, cap 6.
export function extractWikDict(rows) {
const glossMap = new Map();
for (const row of rows ?? []) {
if (!row || !row.written_rep || !row.trans_list) continue;
const key = String(row.written_rep).toLowerCase().trim();
if (!key) continue;
const senses = String(row.trans_list)
.split('|')
.map((s) => s.trim())
.filter(Boolean);
if (!senses.length) continue;
const existing = glossMap.get(key) ?? [];
for (const s of senses) {
if (existing.length >= 6) break;
if (!existing.includes(s)) existing.push(s);
}
glossMap.set(key, existing);
}
return glossMap;
}
// Parse a michmech-style lemmatization list (`lemma<TAB>form` per line, BOM-led)
// into a Map(form-lowercased -> lemma-lowercased). Used to lemmatize a non-English
// SOURCE language so an inflected word (e.g. Spanish "perros") gets glossed via its
// lemma ("perro"). First-wins on the rare ambiguous form.
export function parseLemmatizationList(text) {
const map = new Map();
for (const line of text.replace(/^/, '').split('\n')) {
const tab = line.indexOf('\t');
if (tab === -1) continue;
const lemma = line.slice(0, tab).trim().toLowerCase();
const form = line.slice(tab + 1).trim().toLowerCase();
if (!lemma || !form || form === lemma) continue;
if (!map.has(form)) map.set(form, lemma);
}
return map;
}
// Read a pack file's `inflections` map (form -> lemma) as a Map. Used to lemmatize
// English-source WikDict packs by reusing the en-zh pack's full inflection table.
export function inflectionMapFromPack(jsonText) {
try {
const infl = JSON.parse(jsonText).inflections || {};
return new Map(Object.entries(infl));
} catch {
return new Map();
}
}
// Stream a (possibly ~1 GB) JSONL file line-by-line, applying `perLine(obj)` to
// each parsed object. Shared by the streaming extractors so the CLI never holds
// the whole file in memory; parse errors are skipped silently.
async function streamJsonl(path, perLine) {
const rl = createInterface({
input: createReadStream(path, { encoding: 'utf8' }),
crlfDelay: Infinity,
});
for await (const line of rl) {
if (!line.trim()) continue;
let obj;
try {
obj = JSON.parse(line);
} catch {
continue;
}
perLine(obj);
}
}
// Streaming X→en (file path) — same per-line logic as extractXToEn.
export async function extractXToEnStream(path, sourceCode) {
const glossMap = new Map();
await streamJsonl(path, (obj) => mergeXToEnEntry(obj, sourceCode, glossMap));
return glossMap;
}
// Streaming en→X (file path) — same per-line logic as extractEnToX.
export async function extractEnToXStream(path, targetCode) {
const glossMap = new Map();
await streamJsonl(path, (obj) => mergeEnToXEntry(obj, targetCode, glossMap));
return glossMap;
}
// Assemble a pack in the GlossIndexData shape from a frequency list + gloss map.
// Walks freqList in order, skips the easiest `skipTop`, and emits up to `topN`
// entries; a surface word missing from glossMap falls back to its lemma (via
// lemmaMap). Inflections map each form to a lemma that is itself an entry.
export function buildPack({ freqList, glossMap, meta, topN = 30000, skipTop = 1000, lemmaMap = null }) {
const entries = {};
let count = 0;
for (let i = skipTop; i < freqList.length; i++) {
if (count >= topN) break;
const { word, rank } = freqList[i];
let senses = glossMap.get(word);
if (!senses && lemmaMap) {
const lemma = lemmaMap.get(word);
if (lemma) senses = glossMap.get(lemma);
}
if (!senses || !senses.length) continue;
const g = shortGloss(senses.join(''));
if (!g) continue;
entries[word] = { r: rank, g };
count++;
}
// Inflected surface forms resolve to their lemma's entry via `inflections`, so
// drop any standalone inflected-form entry whose lemma is present (it then
// inherits the lemma's difficulty rank + gloss instead of being glossed itself).
const inflections = {};
if (lemmaMap) {
for (const [form, lemma] of lemmaMap) {
if (!entries[lemma]) continue;
delete entries[form];
inflections[form] = lemma;
}
}
return {
meta: { ...meta, metric: 'frequency', version: 1, count: Object.keys(entries).length },
entries,
inflections,
};
}
// Hex SHA-256 of a UTF-8 string (used for pack integrity in the manifest).
export function sha256Hex(text) {
return createHash('sha256').update(text, 'utf8').digest('hex');
}
// Build a manifest pack entry from a pack file's name + its raw JSON text.
export function packEntry(file, jsonText) {
const data = JSON.parse(jsonText);
const source = data.meta?.source,
target = data.meta?.target;
if (!source || !target) return null; // not a pack file
return {
pair: `${source}-${target}`,
source,
target,
file,
bytes: Buffer.byteLength(jsonText, 'utf8'),
sha256: sha256Hex(jsonText),
entries: Object.keys(data.entries || {}).length,
};
}
// Assemble the manifest object from pack entries (drops nulls, sorts by pair).
export function buildManifest(entries) {
const packs = entries.filter(Boolean).sort((a, b) => a.pair.localeCompare(b.pair));
return { schemaVersion: 1, packs };
}
// (Re)write manifest.json by scanning OUT_DIR for pack json files.
function writeManifest() {
const files = readdirSync(OUT_DIR).filter((f) => f.endsWith('.json') && f !== 'manifest.json');
const entries = files.map((f) => packEntry(f, readFileSync(resolve(OUT_DIR, f), 'utf8')));
const manifest = buildManifest(entries);
writeFileSync(resolve(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
return manifest;
}
// CLI entry point — skipped when imported by tests.
async function main() {
const [pair, ...rest] = process.argv.slice(2);
mkdirSync(OUT_DIR, { recursive: true });
if (pair === 'build') {
const [src, tgt, freqPath, glossPath, topN] = rest;
if (!src || !tgt || !freqPath || !glossPath)
throw new Error(
'usage: build-wordlens-data.mjs build <src> <tgt> <freq.txt> <gloss.jsonl> [topN]',
);
const freqList = parseFrequencyWords(readFileSync(freqPath, 'utf8'));
let glossMap;
if (tgt === 'en') {
glossMap = await extractXToEnStream(glossPath, src);
} else if (src === 'en') {
glossMap = await extractEnToXStream(glossPath, tgt);
} else {
throw new Error("build: one side must be 'en'");
}
const meta = {
source: src,
target: tgt,
license: 'CC-BY-SA-4.0',
attribution:
'Glosses: Wiktionary (CC-BY-SA-4.0) via kaikki.org. Frequency: hermitdave/FrequencyWords from OpenSubtitles/OPUS (CC-BY-SA-4.0).',
};
const data = buildPack({ freqList, glossMap, meta, topN: Number(topN) || TOP_DEFAULT });
const file = `${src}-${tgt}.json`;
writeFileSync(resolve(OUT_DIR, file), JSON.stringify(data));
console.log(`${file}: ${data.meta.count} entries`);
writeManifest();
} else if (pair === 'build-wikdict') {
const [src, tgt, freqPath, dbPath, topN, lemmaFile] = rest;
if (!src || !tgt || !freqPath || !dbPath)
throw new Error(
'usage: build-wordlens-data.mjs build-wikdict <src> <tgt> <freq.txt> <wikdict.sqlite3> [topN] [lemma.txt]',
);
let rows;
try {
const json = execFileSync(
'sqlite3',
['-json', dbPath, 'SELECT written_rep, trans_list FROM simple_translation'],
{ encoding: 'utf8', maxBuffer: 1 << 30 },
);
rows = json.trim() ? JSON.parse(json) : [];
} catch (err) {
throw new Error(
`build-wikdict: failed to read ${dbPath} via the 'sqlite3' CLI (is sqlite3 installed?). ${err.message}`,
);
}
const freqList = parseFrequencyWords(readFileSync(freqPath, 'utf8'));
const glossMap = extractWikDict(rows);
// Lemmatize the SOURCE language so inflected words are glossed via their lemma.
// English source reuses the en-zh pack's inflection table ("kept"->"keep"); a
// non-English source uses an optional michmech lemmatization list ("perros"->
// "perro"). No-op if neither is available.
let lemmaMap = null;
const enZhPath = resolve(OUT_DIR, 'en-zh.json');
if (src === 'en' && existsSync(enZhPath)) {
lemmaMap = inflectionMapFromPack(readFileSync(enZhPath, 'utf8'));
} else if (tgt === 'en' && lemmaFile && existsSync(lemmaFile)) {
lemmaMap = parseLemmatizationList(readFileSync(lemmaFile, 'utf8'));
}
const meta = {
source: src,
target: tgt,
license: 'CC-BY-SA-3.0',
attribution:
'Glosses: WikDict (CC-BY-SA-3.0), derived from DBnary/Wiktionary. Frequency: hermitdave/FrequencyWords from OpenSubtitles/OPUS (CC-BY-SA-4.0).',
};
const data = buildPack({ freqList, glossMap, meta, topN: Number(topN) || 20000, lemmaMap });
const file = `${src}-${tgt}.json`;
writeFileSync(resolve(OUT_DIR, file), JSON.stringify(data));
console.log(`${file}: ${data.meta.count} entries`);
writeManifest();
} else if (pair === 'en-zh') {
const [csv, topN] = rest;
if (!csv) throw new Error('usage: build-wordlens-data.mjs en-zh <ecdict.csv> [topN]');
const data = buildEnZh(readFileSync(csv, 'utf8'), Number(topN) || TOP_DEFAULT);
writeFileSync(resolve(OUT_DIR, 'en-zh.json'), JSON.stringify(data));
console.log(
`en-zh.json: ${data.meta.count} entries, ${Object.keys(data.inflections).length} inflections`,
);
writeManifest();
} else if (pair === 'zh-en') {
const [cedict, hsk, topN] = rest;
if (!cedict || !hsk)
throw new Error('usage: build-wordlens-data.mjs zh-en <cedict.txt> <hsk.json> [topN]');
const data = buildZhEn(
readFileSync(cedict, 'utf8'),
JSON.parse(readFileSync(hsk, 'utf8')),
Number(topN) || TOP_DEFAULT,
);
writeFileSync(resolve(OUT_DIR, 'zh-en.json'), JSON.stringify(data));
console.log(`zh-en.json: ${data.meta.count} entries`);
writeManifest();
} else if (pair === 'manifest') {
const m = writeManifest();
console.log('manifest.json:', m.packs.length, 'packs');
} else {
throw new Error(
'usage: build-wordlens-data.mjs <en-zh|zh-en|build|build-wikdict|manifest> <sources...> [topN]',
);
}
}
// Only run the CLI when executed directly (`node build-wordlens-data.mjs ...`),
// not when imported by the unit tests.
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}