/* The dictionary build. dictionary source → normalize + frequency list → ranks, via build-time surface expansion + data/ → curated glosses that win over the dictionary + grammar lexicon → the function words a dictionary cannot supply + surfaceForms() → the surface index that replaces a runtime analyser ------------------------------------------------------------------ → app/public/dict/band-N.json.gz + manifest.json + seed.sqlite3 Run: npm run dict:build Source choice is automatic. If a KRDICT file has been vendored it wins, because it carries curated learner glosses and a graded difficulty level; otherwise the kaikki extract is used. Either way the manifest records which one produced the files. The output is committed. KRDICT cannot be re-fetched by CI, and the app has to ship the data offline regardless, so the band files are artefacts of the repo rather than of the build machine. */ import { mkdir, writeFile, readFile, rm } from "node:fs/promises"; import { gzipSync } from "node:zlib"; import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; import { DatabaseSync } from "node:sqlite"; import { surfaceForms } from "../../lib/conjugation.js"; import { flatten } from "../../lib/gate.js"; import { bandOf, bandForUnit, REFERENCE_BAND, BANDS } from "../../shared/bands.mjs"; import { readKaikki, KAIKKI_ATTRIBUTION } from "./sources/kaikki.mjs"; import { readKrdict, findKrdict, KRDICT_ATTRIBUTION } from "./sources/krdict.mjs"; import { readFrequency, FREQUENCY_ATTRIBUTION } from "./sources/frequency.mjs"; import { rankByFrequency } from "./freq-forms.mjs"; import { ensureVendor, fetchSource } from "./fetch.mjs"; const ROOT = fileURLToPath(new URL("../../", import.meta.url)); const VENDOR = `${ROOT}vendor/`; const OUT = `${ROOT}app/public/dict/`; const readJson = async (p) => JSON.parse(await readFile(p, "utf8")); const log = (...a) => console.log(...a); /** The dictionary-form suffix every Korean predicate ends in. */ const DICT_FORM = "\uB2E4"; /* ── merge ──────────────────────────────────────────────────────────── One entry per (headword, pos). Later sources overwrite earlier ones, so the precedence order below is the precedence order of the glosses. */ class Lexicon { constructor() { this.byKey = new Map(); this.byHeadword = new Map(); } /** Every entry for a headword, whatever its part of speech. */ forHeadword(headword) { return [...(this.byHeadword.get(headword) ?? [])].map((k) => this.byKey.get(k)); } key(headword, pos) { return `${headword}${pos}`; } add(entry) { const k = this.key(entry.headword, entry.pos); const prev = this.byKey.get(k); // A later source wins, but never by replacing a real gloss with nothing. if (prev && !entry.gloss_en && prev.gloss_en) entry = { ...entry, gloss_en: prev.gloss_en }; if (prev && !entry.gloss_ko && prev.gloss_ko) entry = { ...entry, gloss_ko: prev.gloss_ko }; if (prev && entry.level == null && prev.level != null) entry = { ...entry, level: prev.level }; this.byKey.set(k, entry); let keys = this.byHeadword.get(entry.headword); if (!keys) this.byHeadword.set(entry.headword, (keys = new Set())); keys.add(k); } get size() { return this.byKey.size; } entries() { return [...this.byKey.values()]; } } /* ── sources ──────────────────────────────────────────────────────── */ async function loadDictionary(lex) { const krdict = await findKrdict(VENDOR); if (krdict) { log(` dictionary: KRDICT — ${krdict.replace(ROOT, "")}`); let n = 0; for await (const e of readKrdict(krdict)) { lex.add(e); n++; } log(` ${n} entries`); return { name: "krdict", attribution: KRDICT_ATTRIBUTION, entries: n }; } const file = await fetchSource("kaikki"); log(` dictionary: kaikki — ${file.replace(ROOT, "")}`); log(" (vendor a KRDICT download to prefer it — see tools/dict/sources/krdict.mjs)"); let n = 0; for await (const e of readKaikki(file)) { lex.add(e); n++; } log(` ${n} entries`); return { name: "kaikki", attribution: KAIKKI_ATTRIBUTION, entries: n }; } /** * data/gloss-extra.json — curated glosses for exactly the roadmap words the * deck does not cover. Upstream calls these "gloss only, NOT SRS cards", so * they are applied as an OVERRIDE on whatever the dictionary already has * rather than as new entries: overwriting the gloss where the headword is * already known, and only adding a row where it is not. That keeps one row * per real word instead of shadowing every noun with a second, pos-less copy. */ async function loadGlossExtra(lex) { const extra = await readJson(`${ROOT}data/gloss-extra.json`); let overridden = 0; let added = 0; for (const e of extra.entries) { const existing = lex.forHeadword(e.ko); if (existing.length) { for (const prev of existing) { lex.add({ ...prev, gloss_en: e.en, note: e.note ?? "", source: "curated" }); } overridden++; } else { // Unknown to the dictionary. A -다 headword is a predicate, and haeche() // treats verbs and adjectives alike, so "verb" is safe for generating // its surface forms; anything else is left unclassified. lex.add({ headword: e.ko, pos: e.ko.endsWith(DICT_FORM) ? "verb" : "word", gloss_en: e.en, gloss_ko: "", note: e.note ?? "", level: null, source: "curated", }); added++; } } log(` gloss-extra: ${extra.entries.length} entries (${overridden} overrode a dictionary gloss, ${added} new)`); return extra.entries.length; } /** deck.json — 386 curated words. These glosses beat the dictionary's. */ async function loadDeck(lex) { const deck = await readJson(`${ROOT}data/deck.json`); let n = 0; for (const [topic, rows] of Object.entries(deck.topics)) { for (const [headword, , gloss, pos] of rows) { lex.add({ headword, pos: pos === "phrase" ? "phrase" : pos, gloss_en: gloss, gloss_ko: "", level: null, source: "curated", topic, }); n++; } } log(` curated deck: ${n} entries`); return n; } /** Sentence chunks and sound words — glossed in context, so worth keeping. */ async function loadSentencesAndSfx(lex) { const sentences = await readJson(`${ROOT}data/sentences.json`); const sfx = await readJson(`${ROOT}data/sfx.json`); let n = 0; for (const s of sentences.sentences) { for (const [chunk, gloss] of s.parts) { lex.add({ headword: chunk, pos: "chunk", gloss_en: gloss, gloss_ko: "", level: null, source: "sentence", }); n++; } } for (const item of sfx.items) { lex.add({ headword: item.ko, pos: "sfx", gloss_en: item.en, gloss_ko: "", level: null, source: "sfx", topic: "의성어·의태어", }); n++; } log(` sentence chunks + sound words: ${n} entries`); return n; } /** The hand-written function words. Merged last, so they win outright. */ async function loadGrammar(lex) { const g = await readJson(`${ROOT}tools/dict/grammar-lexicon.json`); for (const e of g.entries) { lex.add({ headword: e.headword, pos: e.pos, gloss_en: e.gloss_en, gloss_ko: e.gloss_ko ?? "", note: e.note ?? "", level: null, source: "grammar", topic: "문법 Grammar", }); } log(` grammar lexicon: ${g.entries.length} entries`); return g.entries.length; } /* ── build ────────────────────────────────────────────────────────── */ async function main() { log("Building the dictionary\n"); await ensureVendor(); await mkdir(OUT, { recursive: true }); const lex = new Lexicon(); const dict = await loadDictionary(lex); await loadGlossExtra(lex); await loadDeck(lex); await loadSentencesAndSfx(lex); await loadGrammar(lex); log(` merged: ${lex.size} distinct (headword, pos)\n`); /* Frequency, via the inverted join — see freq-forms.mjs. */ const freqFile = await fetchSource("frequency"); const counts = await readFrequency(freqFile); const entries = lex.entries(); const ranks = rankByFrequency(entries, counts); log(` frequency: ${counts.size} surface forms → ${ranks.size} lemmas ranked\n`); /* Which unit introduces a word, so curriculum vocabulary is admitted by the band of its own unit rather than by its raw frequency. */ const curriculum = await readJson(`${ROOT}data/curriculum.json`); const units = flatten(curriculum); const introducedIn = new Map(); for (const u of units) { for (const w of u.words ?? []) if (!introducedIn.has(w)) introducedIn.set(w, u.id); } /* Stable ids: sort first so a rebuild produces byte-identical output. */ entries.sort((a, b) => a.headword === b.headword ? a.pos.localeCompare(b.pos) : a.headword.localeCompare(b.headword), ); const lemmas = []; const surfaces = []; let id = 0; for (const e of entries) { id++; const rank = ranks.get(`${e.headword} ${e.pos}`) ?? null; let band = bandOf({ source: e.source, freqRank: rank, level: e.level }); const unit = introducedIn.get(e.headword); if (unit) band = Math.min(band, bandForUnit(unit)); lemmas.push({ id, headword: e.headword, pos: e.pos, freq_rank: rank, level: e.level ?? null, gloss_en: e.gloss_en ?? "", gloss_ko: e.gloss_ko ?? "", unit_band: band, source: e.source, }); /* The surface index. Strictly surfaceForms() plus the headword itself — the extra forms in freq-forms.mjs exist only to score frequency and are deliberately NOT stored, because they are not all real words. */ surfaces.push({ form: e.headword, lemma_id: id, analysis: `headword, ${e.pos}` }); if (e.pos === "verb" || e.pos === "adj") { for (const s of surfaceForms(e.headword, e.gloss_en ?? "")) { if (s.form) surfaces.push({ form: s.form, lemma_id: id, analysis: s.note }); } } } log(` lemmas: ${lemmas.length}`); log(` surface forms: ${surfaces.length}\n`); /* ── emit ── */ const byBand = new Map(); for (const l of lemmas) { if (!byBand.has(l.unit_band)) byBand.set(l.unit_band, { lemmas: [], surfaces: [] }); byBand.get(l.unit_band).lemmas.push(l); } const bandOfLemma = new Map(lemmas.map((l) => [l.id, l.unit_band])); for (const s of surfaces) { const b = bandOfLemma.get(s.lemma_id); byBand.get(b).surfaces.push(s); } const bands = []; const allBands = [...BANDS.map((b) => b.band), REFERENCE_BAND]; for (const band of allBands) { const data = byBand.get(band) ?? { lemmas: [], surfaces: [] }; // Arrays, not objects: the key names would otherwise be ~60% of the file. const payload = { band, columns: { lemma: ["id", "headword", "pos", "freq_rank", "level", "gloss_en", "gloss_ko", "unit_band", "source"], surface: ["form", "lemma_id", "analysis"], }, lemmas: data.lemmas.map((l) => [ l.id, l.headword, l.pos, l.freq_rank, l.level, l.gloss_en, l.gloss_ko, l.unit_band, l.source, ]), surfaces: data.surfaces.map((s) => [s.form, s.lemma_id, s.analysis]), }; const gz = gzipSync(Buffer.from(JSON.stringify(payload), "utf8"), { level: 9 }); const file = `band-${band}.json.gz`; await writeFile(OUT + file, gz); const sha = createHash("sha256").update(gz).digest("hex"); bands.push({ band, file, lemmas: data.lemmas.length, surfaces: data.surfaces.length, bytes: gz.length, sha256: sha, reference: band === REFERENCE_BAND, }); log( ` band ${band}${band === REFERENCE_BAND ? " (reference)" : ""}: ` + `${String(data.lemmas.length).padStart(6)} lemmas, ` + `${String(data.surfaces.length).padStart(6)} surfaces, ` + `${(gz.length / 1024).toFixed(0)} KB`, ); } /* A real SQLite file of band 0, for inspection and for anyone who wants to open the seed with a normal client. The app loads the band files. */ await rm(`${OUT}seed.sqlite3`, { force: true }); const seed = new DatabaseSync(`${OUT}seed.sqlite3`); seed.exec(` CREATE TABLE lemma (id INTEGER PRIMARY KEY, headword TEXT NOT NULL, pos TEXT NOT NULL, freq_rank INTEGER, level TEXT, gloss_en TEXT NOT NULL DEFAULT '', gloss_ko TEXT NOT NULL DEFAULT '', unit_band INTEGER NOT NULL DEFAULT 0, source TEXT NOT NULL); CREATE TABLE surface (form TEXT NOT NULL, lemma_id INTEGER NOT NULL, analysis TEXT NOT NULL, PRIMARY KEY (form, lemma_id)) WITHOUT ROWID; CREATE INDEX surface_form ON surface(form); `); const band0 = byBand.get(0) ?? { lemmas: [], surfaces: [] }; const li = seed.prepare("INSERT INTO lemma VALUES (?,?,?,?,?,?,?,?,?)"); const si = seed.prepare("INSERT OR IGNORE INTO surface VALUES (?,?,?)"); seed.exec("BEGIN"); for (const l of band0.lemmas) li.run(l.id, l.headword, l.pos, l.freq_rank, l.level, l.gloss_en, l.gloss_ko, l.unit_band, l.source); for (const s of band0.surfaces) si.run(s.form, s.lemma_id, s.analysis); seed.exec("COMMIT"); seed.close(); const manifest = { builtWith: { dictionary: dict.name, dictionaryEntries: dict.entries, frequencyForms: counts.size, }, totals: { lemmas: lemmas.length, surfaces: surfaces.length }, bands, attribution: [dict.attribution, FREQUENCY_ATTRIBUTION], notice: "See NOTICE.md. Share-alike applies to this dictionary data, not to the app code.", }; await writeFile(OUT + "manifest.json", JSON.stringify(manifest, null, 2) + "\n"); const total = bands.reduce((a, b) => a + b.bytes, 0); log(`\n total: ${(total / 1024 / 1024).toFixed(1)} MB across ${bands.length} band files`); log(` written to ${OUT.replace(ROOT, "")}`); log("\nNow run: npm run dict:assert"); } await main();