Files
readest/apps/readest-app/scripts/preview-wordlens.mjs
T
Huang Xin b1346bf16d feat(wordlens): en-en glosses, styling, derivation lemmas, display-time cap (#4744)
* feat(wordlens): support en-en monolingual glosses

Gloss difficult English words with a short English definition for learners
reading English with English hints.

- build: buildEnEn + shortDefGloss read ECDICT's English `definition` column
  (first/primary WordNet sense, POS-stripped, drop ;-example, <=24 word-boundary
  with trailing-connector trim). New `en-en` CLI branch; buildEnZh/buildEnEn now
  share a buildEnPack core.
- gating: drop the hardcoded `hint === source` rejections (wordlensSection,
  WordLensPanel) so same-language packs are allowed; availability is decided by
  the manifest (resolvePack returns null when no pack exists).
- data: data/wordlens/en-en.json (26,578 entries) + regenerated manifest.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(wordlens): gloss styling, derivation lemmas, display-time cap

Builds on the en-en monolingual gloss support with refinements and
regenerated packs.

- settings: per-book gloss <rt> font size (em) and color in
  Settings > Language > Word Lens (getRubyStyles reads viewSettings).
- en-en hints: WordNet hybrid (a simpler synonym, else a category
  hypernym, else the ECDICT definition) instead of raw verbose
  definitions.
- lemmatization: gate difficulty by the lemma rank for every English
  source pair. enBaseFormCandidates now also covers -able/-ible suffixes
  and negative prefixes (un/in/im/ir/il), so insufferable resolves to
  suffer. A candidate is accepted when the English definition names the
  base OR the Chinese translations share a content character, which keeps
  true derivations (insufferable -> suffer) and rejects coincidental
  stems (capable -> cap). en-X packs inherit the en-en lemma table.
- display cap: the max gloss length is applied at render time in
  cleanGloss (MAX_GLOSS_LEN), so the packs store the full hint and the
  cap can change without regenerating data.
- tooling: pnpm wordlens:preview to sample pack entries; cache build
  corpora under data/wordlens/.sources (gitignored).
- data: regenerate en-en, en-zh and en-de/es/fr/pt/ru plus the manifest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:15:34 +02:00

67 lines
2.7 KiB
JavaScript

// Preview sample entries from the generated Word Lens gloss packs, to eyeball gloss
// quality per language pair. Read-only; does not modify any pack.
//
// node scripts/preview-wordlens.mjs # a sample from every pack
// node scripts/preview-wordlens.mjs en-en # a larger sample from one pair
// node scripts/preview-wordlens.mjs en-en happy commence thickly # specific words
//
// (pnpm wordlens:preview [pair] [...words])
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const DATA_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../data/wordlens');
// Pick `count` entries spread evenly across the frequency-rank range (commonest
// first, rarest last) so a preview shows both easy and hard words. Deterministic.
export function sampleEntries(entries, count = 12) {
const sorted = Object.entries(entries).sort((a, b) => a[1].r - b[1].r);
if (sorted.length <= count) return sorted;
const step = (sorted.length - 1) / (count - 1);
return Array.from({ length: count }, (_, i) => sorted[Math.round(i * step)]);
}
function previewPack(pair, words, count) {
const path = resolve(DATA_DIR, `${pair}.json`);
if (!existsSync(path)) {
console.log(`\n${pair}: (no pack file)`);
return;
}
const { meta, entries, inflections } = JSON.parse(readFileSync(path, 'utf8'));
const n = Object.keys(entries).length;
console.log(`\n${pair}${meta.source}${meta.target}, ${n} entries, metric=${meta.metric}`);
const rows = words.length
? words.map((w) => {
const key = w.toLowerCase();
const lemma = inflections[key];
const entry = entries[key] ?? (lemma ? entries[lemma] : null);
return [lemma ? `${key}${lemma}` : key, entry];
})
: sampleEntries(entries, count);
for (const [word, entry] of rows) {
if (!entry) console.log(` ${'—'.padStart(6)} ${String(word).padEnd(22)} (no entry)`);
else console.log(` ${String(entry.r).padStart(6)} ${String(word).padEnd(22)} ${entry.g}`);
}
if (!words.length) {
const infl = Object.entries(inflections)
.slice(0, 6)
.map(([f, l]) => `${f}${l}`);
if (infl.length) console.log(` inflections: ${infl.join(', ')}`);
}
}
function listPairs() {
return readdirSync(DATA_DIR)
.filter((f) => f.endsWith('.json') && f !== 'manifest.json')
.map((f) => f.replace(/\.json$/, ''))
.sort();
}
function main() {
const [pair, ...words] = process.argv.slice(2);
if (pair) previewPack(pair, words, words.length ? words.length : 25);
else for (const p of listPairs()) previewPack(p, [], 12);
}
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) main();