From ab9b75ff32ac0ec591bcd2c375aa20fb4d7ee3b0 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 16 Sep 2026 21:59:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(tutor):=20look=20any=20word=20up=20?= =?UTF-8?q?=E2=80=94=20the=20underline,=20the=20popover,=20the=20word=20li?= =?UTF-8?q?st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reworked artifact made every Korean word on the lesson screen a way in to the dictionary, in three tiers. This ports them. 1. The underline. Every Korean run in a message, a gloss or an exercise is a button whose underline says what the word is to him: amber new, blue learning, jade in review, faint when secure or merely explainable, dotted when nothing knows it. A form carries its dictionary word's state — 갔어 is 가다's — through the one resolver the gate uses (domain/words.ts). The artifact gave "in review" no colour at all. 2. The popover, beside the word: the meaning, "+ Add to deck" (the dictionary entry, so 먹었어 adds 먹다), "Ask 선생님" for a word he was shown without being taught, a search, the word list. English on an exercise's English side opens its Korean, from an index of the course's own material — never the frequency bands, whose thousands of glosses would bury the answer. An answer chip's tap belongs to the exercise, so a 420ms hold glosses it, or a tap with 힌트 on; the hold's click is swallowed before the exercise sees it. Pressing a word never takes focus from the answer being typed, and the popover no longer closes when the window resizes — on a phone that was the keyboard moving. 3. The word list gains + / ✓ on every row, search results included. Every lookup, whichever tier, goes into the answer's "I had to look up" and the peek tally — counted under the Korean looked at; the artifact counted an English lookup under the English word. A dictionary word added this way is studied: a card on an entry outside the curated sources now counts as a deck word, in review and in "my units". Before, it got a card that no review would ever show. The word list glosses through the same resolver as the popover and the gate, rather than the surface table alone. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/domain/cards.ts | 15 +- app/src/domain/english.ts | 138 +++++++++ app/src/domain/resolver.ts | 2 +- app/src/domain/words.ts | 142 +++++++++ app/src/ui/tutor/GlossBlock.tsx | 14 +- app/src/ui/tutor/MessageBody.tsx | 22 +- app/src/ui/tutor/TaskHost.tsx | 36 ++- app/src/ui/tutor/TutorTab.tsx | 488 +++++++++++++++++++++---------- app/src/ui/tutor/WordPop.tsx | 121 ++++++++ app/src/ui/tutor/WordRail.tsx | 77 ++++- app/src/ui/tutor/lookup.css | 203 +++++++++++++ app/src/ui/tutor/lookup.tsx | 168 +++++++++++ test/domain/words.test.ts | 127 ++++++++ 13 files changed, 1368 insertions(+), 185 deletions(-) create mode 100644 app/src/domain/english.ts create mode 100644 app/src/domain/words.ts create mode 100644 app/src/ui/tutor/WordPop.tsx create mode 100644 app/src/ui/tutor/lookup.css create mode 100644 app/src/ui/tutor/lookup.tsx create mode 100644 test/domain/words.test.ts diff --git a/app/src/domain/cards.ts b/app/src/domain/cards.ts index a14130f..144da67 100644 --- a/app/src/domain/cards.ts +++ b/app/src/domain/cards.ts @@ -53,8 +53,15 @@ function toCard(row: Record): Card | null { // every word a unit introduces is a card, because the recall evidence and // the phase review both hang off one. const REVIEWABLE = "('curated', 'sfx', 'grammar', 'custom', 'curriculum')"; +const REVIEWABLE_SOURCES = new Set(["curated", "sfx", "grammar", "custom", "curriculum"]); const SENTENCE_SOURCE = "('sentence')"; +/* A card on any other dictionary entry is a word he added himself — from a + lesson's popover, or the add form when the word was already in the + dictionary. Its row stays the dictionary's, so the card is what says it + is his. */ +const ADDED = "(c.lemma_id IS NOT NULL AND l.source <> 'sentence')"; + export interface DeckOptions { /** Mix glossed sentence chunks in, per the session preference. */ sentences?: boolean; @@ -75,7 +82,8 @@ export const MY_UNITS_MIN = 20; * them a word added from a lesson was never reviewed again. */ export function isMine(e: Pick, p: ProgressState): boolean { - if (e.source === "custom") return true; + // His own words, and dictionary words he added (see ADDED). + if (e.source === "custom" || (!REVIEWABLE_SOURCES.has(e.source) && e.source !== "sentence")) return true; if (!e.unitId) return false; const at = unitIndex(e.unitId); return at >= 0 && (Boolean(p.done[e.unitId]) || at <= unitIndex(p.current)); @@ -94,9 +102,8 @@ export function studyPool(entries: DeckEntry[], p: ProgressState): DeckEntry[] { function sourceClause(opts: DeckOptions): string { if (opts.only === "sentences") return `l.source IN ${SENTENCE_SOURCE}`; - return opts.sentences - ? `l.source IN ${REVIEWABLE} OR l.source IN ${SENTENCE_SOURCE}` - : `l.source IN ${REVIEWABLE}`; + const words = `l.source IN ${REVIEWABLE} OR ${ADDED}`; + return opts.sentences ? `${words} OR l.source IN ${SENTENCE_SOURCE}` : words; } export async function deck(db: Db, opts: DeckOptions = {}): Promise { diff --git a/app/src/domain/english.ts b/app/src/domain/english.ts new file mode 100644 index 0000000..848c0a8 --- /dev/null +++ b/app/src/domain/english.ts @@ -0,0 +1,138 @@ +/* English → 한국어, for the English side of an exercise. + + Building a sentence from "The cat is on the table" is exactly when he + needs to know what "table" is. The artifact indexed its whole lexicon on + the English: the gloss whole, each clause of it, and each content word, + so "cafe", "older brother" and "to be big" all resolve, and a tap tries + the longest phrase around the word first. + + The index holds the deck and the course's own material only — curated + words, grammar, sentences, sound words, the curriculum's forms and his + own words. Never the frequency bands: thirty thousand dictionary glosses + would answer "table" with a dozen words he has never been taught. */ + +import type { Db } from "../db/types.js"; + +export interface EnEntry { + ko: string; + gloss: string; + note: string; + pos: string; + /** In his review deck — a sentence chunk is not. */ + inDeck: boolean; +} + +export type EnIndex = Map; + +/** The artifact's list: words that are never worth a lookup. */ +const STOP = new Set([ + "the", "a", "an", "to", "of", "in", "on", "at", "is", "are", "am", "be", "was", "were", + "it", "that", "this", "these", "those", "and", "or", "for", "with", "as", "by", "do", "does", + "did", "not", "no", "my", "your", "our", "their", "his", "her", "its", "i", "you", "he", "she", + "we", "they", "me", "him", "them", "one", "some", "any", "there", "here", "from", "up", "out", + "if", "so", "than", "then", "too", "very", "just", "about", "into", "over", "when", "what", + "who", "which", "how", "why", "has", "have", "had", "will", "would", "can", "could", "may", + "might", "said", "says", "say", "get", "got", "go", "goes", "s", "t", "re", "ll", "ve", +]); + +const PER_KEY = 10; +const MAX_HITS = 8; + +const clean = (k: string) => + k + .toLowerCase() + .replace(/[^a-z0-9' -]/g, "") + .trim(); + +export function buildEnIndex(entries: EnEntry[]): EnIndex { + const index: EnIndex = new Map(); + const add = (key: string, e: EnEntry) => { + const k = clean(key); + if (k.length < 2 || STOP.has(k)) return; + let list = index.get(k); + if (!list) index.set(k, (list = [])); + if (list.length < PER_KEY && !list.some((x) => x.ko === e.ko)) list.push(e); + }; + + for (const e of entries) { + const gloss = e.gloss.toLowerCase(); + if (!gloss) continue; + add(gloss, e); + for (const clause of gloss.split(/[;,/()·]|\s+—\s+|\s+-\s+/)) { + // "to be big" is "big"; the artifact's pattern tried "to" before + // "to be", which left "be big". + const part = clause.trim().replace(/^(to be|to|be)\s+/, ""); + if (!part) continue; + add(part, e); + for (const w of part.split(/\s+/)) add(w, e); + } + } + return index; +} + +/** Whether a word is worth underlining at all. */ +export const isLookupWord = (word: string): boolean => + word.length >= 2 && !STOP.has(word.toLowerCase()); + +/** + * What `words[at]` means in Korean: the longest phrase around it first — + * "older brother" before "brother" — then a lone plural made singular. + */ +export function enLookup(index: EnIndex, words: string[], at: number): EnEntry[] { + const phrase = (ws: string[]) => + ws + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9' ]/g, "") + .replace(/'s\b/g, "") + .trim(); + + const tries: string[] = []; + for (let len = 3; len >= 1; len--) { + for (let start = Math.max(0, at - len + 1); start <= at && start + len <= words.length; start++) { + tries.push(phrase(words.slice(start, start + len))); + } + } + for (const t of [...tries]) { + if (t.includes(" ") || t.length <= 3 || !t.endsWith("s") || t.endsWith("ss")) continue; + tries.push(t.slice(0, -1)); // words → word + if (t.endsWith("es")) tries.push(t.slice(0, -2)); // boxes → box + } + + const seen = new Set(); + const out: EnEntry[] = []; + for (const t of tries) { + for (const e of index.get(t) ?? []) { + if (seen.has(e.ko)) continue; + seen.add(e.ko); + out.push(e); + } + if (out.length >= MAX_HITS) break; + } + return out.slice(0, MAX_HITS); +} + +const NOTE: Record = { + custom: "your own word", + sentence: "seen in a sentence", + sfx: "의성어 · 의태어", +}; + +export async function loadEnglishIndex(db: Db): Promise { + const rows = await db.all<{ ko: string; gloss: string; pos: string; source: string }>( + `SELECT headword AS ko, gloss_en AS gloss, pos, source FROM lemma + WHERE source IN ('curated', 'grammar', 'sentence', 'sfx', 'curriculum', 'custom') + AND gloss_en <> '' + ORDER BY CASE source WHEN 'curated' THEN 0 WHEN 'custom' THEN 1 WHEN 'curriculum' THEN 2 + WHEN 'grammar' THEN 3 WHEN 'sfx' THEN 4 ELSE 5 END`, + ); + return buildEnIndex( + rows.map((r) => ({ + ko: r.ko, + gloss: r.gloss, + note: NOTE[r.source] ?? r.pos, + pos: r.pos, + inDeck: r.source !== "sentence", + })), + ); +} diff --git a/app/src/domain/resolver.ts b/app/src/domain/resolver.ts index 3807760..486de4f 100644 --- a/app/src/domain/resolver.ts +++ b/app/src/domain/resolver.ts @@ -65,7 +65,7 @@ export function lexicon(): Lexicon { const MAX_IN = 900; // Android's bound-parameter ceiling; see db/writes.ts. /** Every dictionary headword each form is a surface of, in one pass. */ -async function dictionaryHeads(db: Db, forms: string[]): Promise> { +export async function dictionaryHeads(db: Db, forms: string[]): Promise> { const out = new Map(); for (let i = 0; i < forms.length; i += MAX_IN) { const batch = forms.slice(i, i + MAX_IN); diff --git a/app/src/domain/words.ts b/app/src/domain/words.ts new file mode 100644 index 0000000..8381874 --- /dev/null +++ b/app/src/domain/words.ts @@ -0,0 +1,142 @@ +/* What a Korean word on the screen is to him. + + Every Korean word in a message, a gloss or an exercise is tappable, and + its underline says what it is — the first tier of looking a word up: + + new in his deck, not studied yet + learning being learned + review in review + known secure + gloss explainable, but not a word in his deck + unknown nothing knows it: a tap offers a search + + A form resolves through the one resolver (resolver.ts): 먹었어 is 먹다's, + so it carries 먹다's state. */ + +import type { WordEntry } from "@lib/blocks.js"; +import { statusOf, type Card } from "@lib/srs.js"; +import type { Db } from "../db/types.js"; +import { dictionaryHeads, glossMany, lexicon, type Gloss } from "./resolver.js"; + +export type WordState = "new" | "learning" | "review" | "known" | "gloss" | "unknown"; + +export interface WordInfo { + token: string; + state: WordState; + gloss: Gloss | null; + /** The deck word it is, or is a form of. */ + head: string | null; + /** A word he studies, or one the course will teach him. */ + inDeck: boolean; + /** The dictionary entry it resolves to — what "add to deck" adds. */ + lemma: { headword: string; pos: string; gloss: string } | null; +} + +/** A card, or a word the review deck holds. See cards.ts. */ +const DECK_SOURCES = "('curated', 'grammar', 'sfx', 'curriculum', 'custom')"; + +const MAX_IN = 900; + +function batches(items: T[]): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += MAX_IN) out.push(items.slice(i, i + MAX_IN)); + return out; +} + +const place = (n: number) => Array.from({ length: n }, () => "?").join(","); + +export async function wordInfo( + db: Db, + tokens: Iterable, + declared: WordEntry[] | null = null, +): Promise> { + const forms = [...new Set(tokens)]; + const out = new Map(); + if (!forms.length) return out; + + const lex = lexicon(); + const glosses = await glossMany(db, forms, declared); + + // What each form could be: itself, then every word lexicon.js routes it + // to, then — only where lexicon.js has no route — the dictionary's. + const candidates = new Map(); + const unrouted: string[] = []; + for (const f of forms) { + const heads = lex.heads(f).filter((h) => h !== f); + candidates.set(f, [f, ...heads]); + if (!heads.length) unrouted.push(f); + } + if (unrouted.length) { + for (const [form, heads] of await dictionaryHeads(db, unrouted)) { + candidates.set(form, [form, ...heads.filter((h) => h !== form)]); + } + } + + // The deck: the best card per headword, a studied card before an unstudied + // entry. Homographs share an underline — 눈 is one word on the screen. + const deck = new Map(); + const words = [...new Set([...candidates.values()].flat())]; + for (const batch of batches(words)) { + const rows = await db.all( + `SELECT l.headword, c.lemma_id AS carded, c.state, c.ease, c.interval, c.due, c.reps, c.lapses + FROM lemma l LEFT JOIN card c ON c.lemma_id = l.id + WHERE l.headword IN (${place(batch.length)}) + AND (l.source IN ${DECK_SOURCES} OR c.lemma_id IS NOT NULL) + ORDER BY c.lemma_id IS NULL, c.state = 0, c.interval DESC`, + batch, + ); + for (const r of rows) { + if (deck.has(r.headword)) continue; + deck.set( + r.headword, + r.carded == null + ? null + : { state: r.state, ease: r.ease, interval: r.interval, due: r.due, reps: r.reps, lapses: r.lapses }, + ); + } + } + + // The dictionary entry behind each form, for adding it. + const lemmas = new Map(); + for (const batch of batches(forms)) { + const rows = await db.all<{ form: string; headword: string; pos: string; gloss: string }>( + `SELECT s.form AS form, l.headword, l.pos, l.gloss_en AS gloss + FROM surface s JOIN lemma l ON l.id = s.lemma_id + WHERE s.form IN (${place(batch.length)}) AND l.gloss_en <> '' + ORDER BY CASE l.source WHEN 'curated' THEN 0 WHEN 'grammar' THEN 1 WHEN 'curriculum' THEN 2 + WHEN 'custom' THEN 3 WHEN 'sfx' THEN 4 ELSE 5 END, + l.freq_rank IS NULL, l.freq_rank`, + batch, + ); + for (const r of rows) { + if (!lemmas.has(r.form)) lemmas.set(r.form, { headword: r.headword, pos: r.pos, gloss: r.gloss }); + } + } + + for (const f of forms) { + const head = candidates.get(f)!.find((c) => deck.has(c)) ?? null; + const gloss = glosses.get(f) ?? null; + let state: WordState; + if (head !== null) { + const status = statusOf(deck.get(head)); + state = status === "secure" ? "known" : status; + } else { + state = gloss?.gloss ? "gloss" : "unknown"; + } + out.set(f, { token: f, state, gloss, head, inDeck: head !== null, lemma: lemmas.get(f) ?? null }); + } + return out; +} + +/** + * What adding a looked-up word adds: its dictionary entry when it has one — + * 먹었어 adds 먹다 — and otherwise the form as he met it. + */ +export function wordToAdd(info: WordInfo): { headword: string; pos: string; gloss: string } { + if (info.lemma) return info.lemma; + return { + headword: info.token, + pos: /다$/.test(info.token) ? "verb" : "noun", + gloss: info.gloss?.gloss || "—", + }; +} diff --git a/app/src/ui/tutor/GlossBlock.tsx b/app/src/ui/tutor/GlossBlock.tsx index 223cd66..2016151 100644 --- a/app/src/ui/tutor/GlossBlock.tsx +++ b/app/src/ui/tutor/GlossBlock.tsx @@ -8,18 +8,20 @@ import { Fragment } from "react"; import type { GlossBlock as Block, GlossPart, GlossRole } from "@lib/blocks.js"; import { ROLE_STYLES, isRole, legendFor } from "./roles.js"; +import { KoText } from "./lookup.js"; import "./gloss.css"; /** Wrap the LAST occurrence of the highlight, which is where a suffix sits. */ function withHighlight(ko: string, highlight: string) { - if (!highlight) return ko; - const at = ko.lastIndexOf(highlight); - if (at < 0) return ko; + const at = highlight ? ko.lastIndexOf(highlight) : -1; + if (at < 0) return ; return ( <> - {ko.slice(0, at)} - {highlight} - {ko.slice(at + highlight.length)} + + + + + ); } diff --git a/app/src/ui/tutor/MessageBody.tsx b/app/src/ui/tutor/MessageBody.tsx index 298da06..f4def49 100644 --- a/app/src/ui/tutor/MessageBody.tsx +++ b/app/src/ui/tutor/MessageBody.tsx @@ -9,6 +9,7 @@ - a line opening with ✓ or ✗ is a marked answer, and gets the colour. */ import { Fragment } from "react"; +import { KoText } from "./lookup.js"; const KOREAN = /[가-힣]/g; const PUNCT = /[\s.,!?·…"'“”()[\]:;~-]/g; @@ -32,14 +33,27 @@ function isKoreanLine(line: string): boolean { learn:*" kept its asterisks, and a "---" divider showed as three dashes. Degrading gracefully costs little and keeps a lesson readable. */ -/** **bold**, and *italic* as a concession to models that use it. */ +/** **bold**, and *italic* as a concession to models that use it. Korean + in any of it can be looked up. */ function inline(text: string) { return text.split(/(\*\*[^*]+\*\*|\*[^*\n]+\*)/g).map((part, i) => { if (part.startsWith("**") && part.endsWith("**") && part.length > 4) - return {part.slice(2, -2)}; + return ( + + + + ); if (part.startsWith("*") && part.endsWith("*") && part.length > 2) - return {part.slice(1, -1)}; - return {part}; + return ( + + + + ); + return ( + + + + ); }); } diff --git a/app/src/ui/tutor/TaskHost.tsx b/app/src/ui/tutor/TaskHost.tsx index 7579fbd..769251c 100644 --- a/app/src/ui/tutor/TaskHost.tsx +++ b/app/src/ui/tutor/TaskHost.tsx @@ -23,6 +23,7 @@ import { answerText } from "@lib/blocks.js"; import { recallLetterBlock } from "../../domain/letters.js"; import { useComposer } from "../keyboard/Keyboard.js"; import { useFields, type KeyField } from "./fields.js"; +import { EnText, KoText, useChipGloss } from "./lookup.js"; import "./task.css"; /* A shuffle seed from the turn's id — a string now, see db/ids.ts. */ @@ -155,7 +156,9 @@ function Translate({ task, turnId, answers, update, disabled, onEnter }: FieldsP const id = `${turnId}:${i}`; return (
- {it.q} + + + - {it.q} + {it.hint && · {it.hint}} {done.map((p, i) => ( - {task.pairs[p.left]!.ko} = {task.pairs[p.right]!.gloss} + + + {" "} + = + )}
diff --git a/app/src/ui/tutor/TutorTab.tsx b/app/src/ui/tutor/TutorTab.tsx index e6fdf20..21dbcae 100644 --- a/app/src/ui/tutor/TutorTab.tsx +++ b/app/src/ui/tutor/TutorTab.tsx @@ -21,7 +21,15 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useStore } from "../../state/store.js"; -import { editChatClear, editChatRemove, editChatTurn, pruneChat, seedChatTurn } from "../../db/writes.js"; +import { + editAddCustomWord, + editChatClear, + editChatRemove, + editChatTurn, + editPeek, + pruneChat, + seedChatTurn, +} from "../../db/writes.js"; import { parseMessage } from "../../domain/gloss.js"; import { gateFor, @@ -36,7 +44,9 @@ import type { FocusMode } from "../../domain/gate.js"; import { currentUnit, noteAnswer } from "../../domain/progress.js"; import { makeStubTutor, SampleError, type Sample, type StubWord } from "../../domain/stub-tutor.js"; import { makeRemoteTutor } from "../../domain/tutor-client.js"; -import { lookupMany } from "../../domain/lexicon.js"; +import { koreanTokens, lookupMany } from "../../domain/lexicon.js"; +import { wordInfo, wordToAdd, type WordInfo } from "../../domain/words.js"; +import { enLookup, isLookupWord, loadEnglishIndex, type EnIndex } from "../../domain/english.js"; import { applyReply, readRecent, runTurn } from "../../domain/turn.js"; import { coverage } from "../../domain/ledger.js"; import type { Retry } from "../../domain/prompt-tail.js"; @@ -52,6 +62,8 @@ import { WordSheet, type Detent } from "./WordSheet.js"; import { RoadStrip } from "./RoadStrip.js"; import { AnswerBar, Composer } from "./Composer.js"; import { FieldsContext, liveAnswerFields, type Fields, type KeyField } from "./fields.js"; +import { LookupContext, type Lookup } from "./lookup.js"; +import { EnPop, KoPop, type PopTarget } from "./WordPop.js"; import { Keyboard } from "../keyboard/Keyboard.js"; import { RouteHead, useRouteActive } from "../shell/Route.js"; import { useLayer } from "../shell/router.js"; @@ -171,6 +183,14 @@ export function TutorTab() { /** Flagged words per turn id, so the flag stays under its own message. */ const [flags, setFlags] = useState>({}); const [roadOpen, setRoadOpen] = useState(false); + /** The word popover, and what the word it is open on is to him. */ + const [pop, setPop] = useState(null); + const [popInfo, setPopInfo] = useState(null); + /** Every Korean word in the transcript, for its underline. */ + const [words, setWords] = useState>(new Map()); + const [enIndex, setEnIndex] = useState(null); + /** Added from an English popover this session, for its ✓. */ + const [addedEn, setAddedEn] = useState>(new Set()); const [menuOpen, setMenuOpen] = useState(false); const [detent, setDetent] = useState("closed"); @@ -556,6 +576,109 @@ export function TutorTab() { [turns], ); + /* The first tier: what every Korean word on the screen is to him. Read + again when the transcript changes, and whenever cards may have. */ + useEffect(() => { + let cancelled = false; + const tokens = koreanTokens(turns.map((t) => t.body).join("\n")); + const declared = parsedTurns.flatMap(({ parsed }) => parsed?.words ?? []); + void wordInfo(db, tokens, declared).then((m) => { + if (!cancelled) setWords(m); + }); + return () => { + cancelled = true; + }; + }, [db, parsedTurns, revision, turns]); + + useEffect(() => { + let cancelled = false; + void loadEnglishIndex(db).then((index) => { + if (!cancelled) setEnIndex(index); + }); + return () => { + cancelled = true; + }; + }, [db, revision]); + + /* The second tier. Any lookup is a lookup — for the answer's "I had to + look up" and for the peek tally, which counts the Korean he looked at: + the artifact counted an English lookup under the English word. */ + const lookedUp = useCallback( + (kos: string[]) => { + setRevealed((r) => new Set([...r, ...kos])); + for (const ko of kos) void editPeek(db, ko); + }, + [db], + ); + + const openKo = useCallback( + (anchor: HTMLElement, token: string) => { + setPop({ kind: "ko", token, anchor }); + lookedUp([token]); + }, + [lookedUp], + ); + + const openEn = useCallback( + (anchor: HTMLElement, word: string, list: string[], at: number) => { + const hits = enIndex ? enLookup(enIndex, list, at) : []; + if (!hits.length) return; + setPop({ kind: "en", word, hits, anchor }); + lookedUp(hits.map((h) => h.ko)); + }, + [enIndex, lookedUp], + ); + + const lookup = useMemo( + () => ({ + stateOf: (token) => words.get(token)?.state, + hasEnglish: (list, at) => + enIndex !== null && isLookupWord(list[at] ?? "") && enLookup(enIndex, list, at).length > 0, + openKo, + openEn, + }), + [enIndex, openEn, openKo, words], + ); + + useEffect(() => { + if (pop?.kind !== "ko") { + setPopInfo(null); + return; + } + const known = words.get(pop.token); + if (known) { + setPopInfo(known); + return; + } + // A chip or a word the transcript's scan did not reach. + let cancelled = false; + setPopInfo(null); + void wordInfo(db, [pop.token]).then((m) => { + if (!cancelled) setPopInfo(m.get(pop.token) ?? null); + }); + return () => { + cancelled = true; + }; + }, [db, pop, words]); + + // The word it is open on stays highlighted while it is. + useEffect(() => { + const el = pop?.anchor; + if (!el) return; + el.dataset.open = "1"; + return () => { + delete el.dataset.open; + }; + }, [pop]); + + const addWord = useCallback( + async (word: { headword: string; pos: string; gloss: string }) => { + await editAddCustomWord(db, word); + invalidate(); + }, + [db, invalidate], + ); + /* parseMessage() re-parsed the entire partial reply on every token, and on every unrelated re-render of the lesson. */ const streamingBody = useMemo(() => { @@ -717,6 +840,16 @@ export function TutorTab() { setDetent("half"); }; + /** The popover's "Word list": the column is already there on a wide screen. */ + const openList = () => { + if (wide) return; + if (answering) { + if (detent === "closed") toggleWords(); + return; + } + setDetent((d) => (d === "closed" || d === "peek" ? "half" : d)); + }; + useEffect(() => { if (detent !== "closed" || !keepAnswering.current) return; const field = resumeField.current; @@ -770,6 +903,8 @@ export function TutorTab() { onReveal: reveal, query: railQuery, onQuery: setRailQuery, + infoOf: (ko: string) => words.get(ko), + onAdd: addWord, }; return ( @@ -856,167 +991,216 @@ export function TutorTab() {
- -
-
{ - // Typing an answer brings the sheet down to peek: what is being - // answered is never behind it. - const t = e.target as HTMLElement; - if (keepAnswering.current) return; - if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) { - setDetent("peek"); - } + setPop(null)} + anchor={pop?.anchor ?? null} + label={pop?.kind === "en" ? "English to Korean" : "Word"} + > + {pop?.kind === "ko" && ( + { + if (popInfo) void addWord(wordToAdd(popInfo)); + setPop(null); }} - onInputCapture={() => { - if (answering) recount(); + onAsk={() => { + setPop(null); + void send( + `You used ${pop.token} in that exercise, but you never taught it and it is not in my word list. What does it mean — and was it meant to be there at all?`, + ); }} - > - setRoadOpen(false)} - busy={busy} - onUnitChange={(id, how) => { - opened.current = true; - const u = UNITS.find((x) => x.id === id); - if (!u) return; - void send( - how === "advance" - ? `좋아 — I'm ready. Let's start unit ${u.id} ${u.ko} (${u.name}). Introduce it in a sentence or two and give me a first exercise.` - : `Let's work on unit ${u.id} ${u.ko} (${u.name}). Give me an exercise for it.`, - ); - }} - /> + onFind={() => { + setRailQuery(pop.token); + setPop(null); + openList(); + }} + onList={() => { + setRailQuery(""); + setPop(null); + openList(); + }} + /> + )} + {pop?.kind === "en" && ( + { + setAddedEn((a) => new Set(a).add(e.ko)); + void addWord({ headword: e.ko, pos: e.pos, gloss: e.gloss }); + }} + /> + )} + -
- {parsedTurns.map(({ turn: t, parsed }, i) => { - const you = t.role === "user"; - const body = parsed ? parsed.body : ownWords(t.body); - return ( -
- {you ? "나" : "선생님"} - {/* A turn can be nothing but blocks — some models write no - prose around an exercise at all. Rendering the bubble - anyway left an empty box above it. */} - {(body.trim() || parsed?.gloss) && ( + + +
+
{ + // Typing an answer brings the sheet down to peek: what is being + // answered is never behind it. + const t = e.target as HTMLElement; + if (keepAnswering.current) return; + if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) { + setDetent("peek"); + } + }} + onInputCapture={() => { + if (answering) recount(); + }} + > + setRoadOpen(false)} + busy={busy} + onUnitChange={(id, how) => { + opened.current = true; + const u = UNITS.find((x) => x.id === id); + if (!u) return; + void send( + how === "advance" + ? `좋아 — I'm ready. Let's start unit ${u.id} ${u.ko} (${u.name}). Introduce it in a sentence or two and give me a first exercise.` + : `Let's work on unit ${u.id} ${u.ko} (${u.name}). Give me an exercise for it.`, + ); + }} + /> + +
+ {parsedTurns.map(({ turn: t, parsed }, i) => { + const you = t.role === "user"; + const body = parsed ? parsed.body : ownWords(t.body); + return ( +
+ {you ? "나" : "선생님"} + {/* A turn can be nothing but blocks — some models write no + prose around an exercise at all. Rendering the bubble + anyway left an empty box above it. */} + {(body.trim() || parsed?.gloss) && ( +
+ + {parsed?.gloss && } +
+ )} + + {flags[t.id] && ( +

+ Not taught yet: {flags[t.id]!.join(" · ")} +

+ )} + + {/* An answered exercise stays rendered, read-only. It used + to collapse to a single line, which threw away what the + learner had typed and dropped ~170px out of the log the + instant they pressed Send — the largest single jump in + the whole view. */} + {parsed?.task && ( + void send(message, { lookups: [...revealed] })} + submitRef={isLast(i) ? submitTask : undefined} + /> + )} +
+ ); + })} + + {/* One slot for the turn in flight, keyed so the node survives + the change from waiting to streaming. As two sibling + conditionals the dots unmounted and the text mounted in + their place, which read as a blink at the moment the first + token arrived. */} + {busy && ( +
+ 선생님 + {/* Keep the dots up while the reply so far is only block + markup: there is genuinely nothing to read yet, and an + empty bubble reads as a failure rather than as waiting. */} + {streamingBody.trim() === "" ? ( +
+ + + +
+ ) : (
- - {parsed?.gloss && } +
)} - - {flags[t.id] && ( -

- Not taught yet: {flags[t.id]!.join(" · ")} -

- )} - - {/* An answered exercise stays rendered, read-only. It used - to collapse to a single line, which threw away what the - learner had typed and dropped ~170px out of the log the - instant they pressed Send — the largest single jump in - the whole view. */} - {parsed?.task && ( - void send(message, { lookups: [...revealed] })} - submitRef={isLast(i) ? submitTask : undefined} - /> - )}
- ); - })} + )} +
- {/* One slot for the turn in flight, keyed so the node survives - the change from waiting to streaming. As two sibling - conditionals the dots unmounted and the text mounted in - their place, which read as a blink at the moment the first - token arrived. */} - {busy && ( -
- 선생님 - {/* Keep the dots up while the reply so far is only block - markup: there is genuinely nothing to read yet, and an - empty bubble reads as a failure rather than as waiting. */} - {streamingBody.trim() === "" ? ( -
- - - -
- ) : ( -
- -
- )} -
- )} + toggleKeyboard(false)} + messageField={messageField} + note={note} + onSend={(text) => void send(text, { restore: true })} + onStop={() => abort.current?.abort()} + > + {answering && ( + 0} + canNext={onField && at < answerFields.length - 1} + busy={busy} + onDone={() => { + (document.activeElement as HTMLElement | null)?.blur(); + exitAnswering(); + }} + onWords={toggleWords} + onKeyboard={() => toggleKeyboard(true)} + onPrev={() => step(-1)} + onNext={() => step(1)} + onSubmit={() => { + exitAnswering(); + if (!kbManual.current) setKeyboard(false); + submitTask.current?.(); + }} + /> + )} + {keyboard && target && !parked && ( + { + const el = target.el(); + if (el && document.activeElement !== el) el.focus({ preventScroll: true }); + }} + onDismiss={() => setKeyboard(false)} + /> + )} +
- toggleKeyboard(false)} - messageField={messageField} - note={note} - onSend={(text) => void send(text, { restore: true })} - onStop={() => abort.current?.abort()} - > - {answering && ( - 0} - canNext={onField && at < answerFields.length - 1} - busy={busy} - onDone={() => { - (document.activeElement as HTMLElement | null)?.blur(); - exitAnswering(); - }} - onWords={toggleWords} - onKeyboard={() => toggleKeyboard(true)} - onPrev={() => step(-1)} - onNext={() => step(1)} - onSubmit={() => { - exitAnswering(); - if (!kbManual.current) setKeyboard(false); - submitTask.current?.(); - }} - /> - )} - {keyboard && target && !parked && ( - { - const el = target.el(); - if (el && document.activeElement !== el) el.focus({ preventScroll: true }); - }} - onDismiss={() => setKeyboard(false)} - /> - )} - + {wide && ( + + )}
- - {wide && ( - - )} -
- + + {!wide && active && ( diff --git a/app/src/ui/tutor/WordPop.tsx b/app/src/ui/tutor/WordPop.tsx new file mode 100644 index 0000000..613984a --- /dev/null +++ b/app/src/ui/tutor/WordPop.tsx @@ -0,0 +1,121 @@ +/* The popover: one word, what it means, and what to do about it. */ + +import type { WordInfo } from "../../domain/words.js"; +import type { EnEntry } from "../../domain/english.js"; + +export type PopTarget = + | { kind: "ko"; token: string; anchor: HTMLElement } + | { kind: "en"; word: string; hits: EnEntry[]; anchor: HTMLElement }; + +/** Its buttons must not take focus from an answer being typed either. */ +const keepFocus = (e: { preventDefault: () => void }) => e.preventDefault(); + +export function KoPop({ + token, + info, + stray, + onAdd, + onAsk, + onFind, + onList, +}: { + token: string; + /** Null while it is being looked up. */ + info: WordInfo | null; + /** 선생님 used it in the last exercise without having taught it. */ + stray: boolean; + onAdd: () => void; + onAsk: () => void; + onFind: () => void; + onList: () => void; +}) { + const gloss = info?.gloss?.gloss; + const notes = [ + info?.gloss?.note, + info?.head && info.head !== token && !info.gloss?.note.includes(info.head) ? `a form of ${info.head}` : "", + ].filter(Boolean); + + return ( +
+
+ {token} + {gloss ? ( + {gloss} + ) : ( + + {!info + ? "…" + : stray + ? "선생님 used this without teaching it — ask him" + : "not in the word list yet"} + + )} + {notes.length > 0 && {notes.join(" · ")}} +
+
+ {gloss ? ( + + ) : stray ? ( + + ) : ( + + )} + +
+
+ ); +} + +export function EnPop({ + word, + hits, + added, + onAdd, +}: { + word: string; + hits: EnEntry[]; + /** Added from this popover just now. */ + added: Set; + onAdd: (e: EnEntry) => void; +}) { + return ( +
+
+ English → 한국어 + {word} +
+
+ {hits.map((e) => { + const inDeck = e.inDeck || added.has(e.ko); + return ( +
+ {e.ko} + + {e.gloss} + {e.note && {e.note}} + + +
+ ); + })} +
+
+ ); +} diff --git a/app/src/ui/tutor/WordRail.tsx b/app/src/ui/tutor/WordRail.tsx index 9602b11..a861c29 100644 --- a/app/src/ui/tutor/WordRail.tsx +++ b/app/src/ui/tutor/WordRail.tsx @@ -23,8 +23,10 @@ import { useEffect, useState, type ReactNode } from "react"; import type { WordEntry } from "@lib/blocks.js"; import { useStore } from "../../state/store.js"; import { editPeek } from "../../db/writes.js"; -import { koreanTokens, lookupMany, search, type Entry } from "../../domain/lexicon.js"; +import { koreanTokens, search, type Entry } from "../../domain/lexicon.js"; import { ensureReferenceBand } from "../../domain/dictionary.js"; +import { glossMany } from "../../domain/resolver.js"; +import type { WordInfo } from "../../domain/words.js"; import "./rail.css"; export interface RailWord { @@ -40,8 +42,8 @@ const SEARCH_LIMIT = 60; /** * Merge the tutor's ::words block with a scan of the message. Tutor entries - * win; scanned tokens the dictionary cannot gloss are dropped rather than - * shown blank. + * win; scanned tokens are glossed by the one resolver the popover and the + * gate use, and those it cannot gloss are dropped rather than shown blank. */ export async function collectWords( db: ReturnType["db"], @@ -59,17 +61,12 @@ export async function collectWords( const tokens = koreanTokens(text).filter((t) => !seen.has(t)); if (tokens.length) { - const found = await lookupMany(db, tokens); + const found = await glossMany(db, tokens); for (const t of tokens) { const hit = found.get(t); - if (!hit || seen.has(t)) continue; + if (!hit?.gloss || seen.has(t)) continue; seen.add(t); - out.push({ - ko: t, - gloss: hit.glossEn, - note: hit.analysis && hit.headword !== t ? hit.analysis : "", - fromTutor: false, - }); + out.push({ ko: t, gloss: hit.gloss, note: hit.note, fromTutor: false }); } } @@ -88,11 +85,48 @@ export interface RailPanelProps { titleId?: string; /** Buttons at the end of the heading row — the sheet's expand and close. */ actions?: ReactNode; + /** What a word of the lesson is to him — whether it is in his deck. */ + infoOf: (ko: string) => WordInfo | undefined; + onAdd: (word: { headword: string; pos: string; gloss: string }) => void; } -export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId, actions }: RailPanelProps) { +const DECK_SOURCES = new Set(["curated", "grammar", "sfx", "curriculum", "custom"]); + +/** + adds the word to his deck; ✓ says it is there. */ +function AddButton({ ko, inDeck, onAdd }: { ko: string; inDeck: boolean; onAdd: () => void }) { + return ( + + ); +} + +export function RailPanel({ + words, + revealed, + onReveal, + query, + onQuery, + titleId, + actions, + infoOf, + onAdd, +}: RailPanelProps) { const { db, prefs, setPref } = useStore(); const [results, setResults] = useState([]); + /** Added from this list just now: a search row cannot tell otherwise. */ + const [added, setAdded] = useState>(new Set()); + const add = (word: { headword: string; pos: string; gloss: string }) => { + setAdded((a) => new Set(a).add(word.headword)); + onAdd(word); + }; const searching = query.trim().length > 0; useEffect(() => { @@ -169,6 +203,11 @@ export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId, {r.glossEn} {r.pos} + add({ headword: r.headword, pos: r.pos, gloss: r.glossEn })} + />
)) ) : ( @@ -199,6 +238,20 @@ export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId, {w.note && {w.note}} )} + { + const info = infoOf(w.ko); + add( + info?.lemma ?? { + headword: w.ko, + pos: /다$/.test(w.ko) ? "verb" : "noun", + gloss: w.gloss, + }, + ); + }} + />
); }) diff --git a/app/src/ui/tutor/lookup.css b/app/src/ui/tutor/lookup.css new file mode 100644 index 0000000..5a2f921 --- /dev/null +++ b/app/src/ui/tutor/lookup.css @@ -0,0 +1,203 @@ +/* Looking a word up: the underlines, the popover's lists, the add button, + and 힌트. */ + +/* text-decoration follows the font's own metrics. An inset box-shadow sat + on top of ㄲ and ㄹ descenders and made them hard to read. */ +.kw, +.kw-en { + padding: 0 1px; + border: none; + border-radius: 2px; + background: none; + font: inherit; + color: inherit; + letter-spacing: inherit; + cursor: pointer; + text-decoration-line: underline; + -webkit-tap-highlight-color: transparent; +} + +.kw { + text-decoration-thickness: 2px; + text-underline-offset: 0.24em; + text-decoration-skip-ink: none; + text-decoration-color: var(--line2); +} + +.kw[data-s="new"] { + text-decoration-color: var(--hwang); +} + +.kw[data-s="learning"] { + text-decoration-color: var(--r-sub); +} + +/* The artifact had no colour for a word in review: it fell back to the + quiet grey of a word he does not study. */ +.kw[data-s="review"] { + text-decoration-color: var(--jade); +} + +/* Secure, or explainable but not his: present, but quiet. */ +.kw[data-s="known"], +.kw[data-s="gloss"] { + text-decoration-thickness: 1px; + text-decoration-color: var(--line2); +} + +/* Nothing knows it: the tap offers a search. */ +.kw[data-s="unknown"] { + text-decoration-style: dotted; + text-decoration-thickness: 1px; +} + +/* In a gloss block the role already carries an underline and the meaning + is printed beneath, so a state marker would be a second, competing line. + Still tappable, just not marked twice. */ +.gloss .kw { + text-decoration: none; +} + +.kw-en { + text-decoration-style: dotted; + text-decoration-thickness: 1.5px; + text-decoration-color: var(--ink3); + text-underline-offset: 0.2em; +} + +.kw:active, +.kw[data-open="1"], +.kw-en:active, +.kw-en[data-open="1"] { + background: var(--jade-soft); +} + +/* ── the popover's content ───────────────────────────────────────── */ + +.pop-m.dim { + color: var(--ink3); +} + +.pop-en { + font-size: 20px; + font-weight: 600; +} + +.pop-list { + max-height: 44vh; + overflow-y: auto; + overscroll-behavior: contain; + border-top: 1px solid var(--line); +} + +.pop-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + border-bottom: 1px solid var(--line); +} + +.pop-row:last-child { + border-bottom: none; +} + +.pop-row .k { + min-width: 64px; + font-size: 19px; + font-weight: 500; + word-break: keep-all; +} + +.pop-row .m { + flex: 1; + min-width: 0; + font-size: 13.5px; +} + +.pop-row .m i { + display: block; + font-style: normal; + font-size: 11.5px; + color: var(--ink3); +} + +/* ── add to the deck ─────────────────────────────────────────────── */ + +.wr-add { + position: relative; + width: 32px; + height: 32px; + flex: none; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line2); + border-radius: 8px; + font-size: 14px; + color: var(--ink3); +} + +/* A 44px target. */ +.wr-add::before { + content: ""; + position: absolute; + inset: -6px; +} + +.wr-add:hover:not(:disabled) { + border-color: var(--jade); + color: var(--jade); +} + +.wr-add[data-in="1"] { + border-color: var(--jade); + background: var(--jade-soft); + color: var(--jade-ink); + cursor: default; +} + +/* ── 힌트 ────────────────────────────────────────────────────────── */ + +.hintbtn { + flex: none; + min-height: 30px; + padding: 4px 11px; + border: 1px solid var(--line2); + border-radius: 14px; + background: var(--raise); + font-size: 12px; + color: var(--ink2); +} + +.hintbtn[aria-pressed="true"] { + border-color: var(--hwang); + background: var(--hwang); + color: #fff; +} + +.task-h .hint + .hintbtn { + margin-left: 8px; +} + +.task-h .hintbtn:first-of-type { + margin-left: auto; +} + +@media (max-width: 420px) { + .task-h .hint { + display: none; + } + .task-h .hint + .hintbtn { + margin-left: auto; + } +} + +/* A chip being held should not select its text or open a callout. */ +.mt-chip, +.chip-w, +.ch-opts button { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} diff --git a/app/src/ui/tutor/lookup.tsx b/app/src/ui/tutor/lookup.tsx new file mode 100644 index 0000000..5dd2f42 --- /dev/null +++ b/app/src/ui/tutor/lookup.tsx @@ -0,0 +1,168 @@ +/* Looking a word up, in three tiers. + + 1. Every Korean word in a message, a gloss or an exercise is a button, + and its underline says what it is to him (domain/words.ts) — ambient, + at no cost in space. + 2. Tapping one opens a popover beside it. English on an exercise's + English side opens its Korean. An answer chip's tap belongs to the + exercise, so a press-and-hold glosses the chip instead — or a single + tap, with 힌트 switched on. + 3. The whole word list, in the sheet or the docked column. + + Every lookup is a lookup: it goes into the answer's "I had to look up" + and the peek tally, whichever tier it came through. Pressing a word never + takes focus from the answer being typed — that would close the keyboard + under his finger. */ + +import { + createContext, + useContext, + useRef, + type MouseEvent, + type PointerEvent, + type ReactNode, +} from "react"; +import type { WordState } from "../../domain/words.js"; +import "./lookup.css"; + +export interface Lookup { + stateOf: (token: string) => WordState | undefined; + /** Whether `words[at]` has a Korean meaning worth offering. */ + hasEnglish: (words: string[], at: number) => boolean; + openKo: (anchor: HTMLElement, token: string) => void; + openEn: (anchor: HTMLElement, word: string, words: string[], at: number) => void; +} + +export const LookupContext = createContext(null); + +/** Pressing a word must not move focus off the field being answered. */ +const keepFocus = (e: PointerEvent | MouseEvent) => e.preventDefault(); + +/** Korean runs in `text` become lookups; the rest stays text. */ +export function KoText({ text }: { text: string }): ReactNode { + const lookup = useContext(LookupContext); + if (!lookup || !/[가-힣]/.test(text)) return text; + return text.split(/([가-힣]+)/).map((part, i) => + i % 2 ? ( + + ) : ( + part + ), + ); +} + +/** + * English words with a Korean meaning become lookups — only those: an + * underline under every English word would be noise. Korean in the same + * text is wrapped as KoText does. + */ +export function EnText({ text }: { text: string }): ReactNode { + const lookup = useContext(LookupContext); + if (!lookup) return text; + const parts = text.split(/([A-Za-z][A-Za-z'-]*)/); + const words = parts.filter((_, i) => i % 2 === 1); + let at = -1; + return parts.map((part, i) => { + if (i % 2 === 0) return part ? : null; + const here = ++at; + if (!lookup.hasEnglish(words, here)) return part; + return ( + + ); + }); +} + +const CHIPS = ".mt-chip, .chip-w, .ch-opts button"; +const HOLD_MS = 420; + +/** + * Gloss an exercise's answer chips: a press-and-hold on any of them, or a + * tap while `hint` is on. The hold's own click is swallowed in the capture + * phase, before the exercise sees it — a gloss must not also answer. + */ +export function useChipGloss(hint: boolean) { + const lookup = useContext(LookupContext); + const timer = useRef | undefined>(undefined); + const swallow = useRef(false); + + const gloss = (chip: HTMLElement) => { + const text = (chip.textContent ?? "").trim(); + if (!text || !lookup) return; + const korean = text.match(/[가-힣]+/); + if (korean) lookup.openKo(chip, korean[0]); + else { + const words = text.split(/\s+/); + lookup.openEn(chip, words[0]!, words, 0); + } + }; + + const cancel = () => { + clearTimeout(timer.current); + timer.current = undefined; + }; + + return { + /** Spread on the exercise's root. */ + root: { + onPointerDown: (e: PointerEvent) => { + // A hold that never became a click must not swallow the next tap. + swallow.current = false; + const chip = (e.target as HTMLElement).closest(CHIPS); + if (!chip || chip.disabled) return; + cancel(); + timer.current = setTimeout(() => { + timer.current = undefined; + swallow.current = true; + navigator.vibrate?.(8); + gloss(chip); + }, HOLD_MS); + }, + onPointerUp: cancel, + onPointerCancel: cancel, + onPointerMove: (e: PointerEvent) => { + if (timer.current && !(e.target as HTMLElement).closest(CHIPS)) cancel(); + }, + onClickCapture: (e: MouseEvent) => { + const chip = (e.target as HTMLElement).closest(CHIPS); + if (!chip) return; + if (swallow.current || hint) { + e.preventDefault(); + e.stopPropagation(); + if (!swallow.current) gloss(chip); + swallow.current = false; + } + }, + onContextMenu: (e: MouseEvent) => { + // A long press on Android opens the context menu over the chip. + if ((e.target as HTMLElement).closest(CHIPS)) e.preventDefault(); + }, + }, + }; +} diff --git a/test/domain/words.test.ts b/test/domain/words.test.ts new file mode 100644 index 0000000..6f2f7b6 --- /dev/null +++ b/test/domain/words.test.ts @@ -0,0 +1,127 @@ +/* Looking a word up: what its underline says, what adding it adds, and + English → 한국어 — against the shipped dictionary. */ + +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import type { Db } from "@app/db/types.js"; +import { editAddCustomWord, editCard } from "@app/db/writes.js"; +import { wordInfo, wordToAdd } from "@app/domain/words.js"; +import { buildEnIndex, enLookup, isLookupWord, loadEnglishIndex } from "@app/domain/english.js"; +import { cardLemmaFor } from "@app/domain/evidence.js"; +import { deck } from "@app/domain/cards.js"; +import { SECURE_INTERVAL, newCard } from "@lib/srs.js"; +import { dictionaryDb } from "../helpers/dict-db.js"; + +let db: Db; + +beforeAll(async () => { + db = await dictionaryDb(1); +}); +afterAll(async () => { + vi.unstubAllGlobals(); + await db.close(); +}); + +describe("the underline", () => { + it("marks a deck word he has not studied as new, and a secure one as known", async () => { + let info = (await wordInfo(db, ["바다"])).get("바다")!; + expect(info).toMatchObject({ state: "new", inDeck: true, head: "바다" }); + expect(info.gloss?.gloss).toBeTruthy(); + + const id = (await cardLemmaFor(db, "바다"))!; + await editCard(db, id, { ...newCard(), state: 2, interval: SECURE_INTERVAL, due: 99_999, reps: 6 }); + info = (await wordInfo(db, ["바다"])).get("바다")!; + expect(info.state).toBe("known"); + }); + + it("gives a form its dictionary word's state", async () => { + const id = (await cardLemmaFor(db, "가다"))!; + await editCard(db, id, { ...newCard(), state: 1, due: 0, reps: 1 }); + const info = (await wordInfo(db, ["갔어"])).get("갔어")!; + expect(info.state).toBe("learning"); + expect(info.head).toBe("가다"); + }); + + it("calls a word only the dictionary knows explainable, and nothing at all unknown", async () => { + const row = await db.get<{ headword: string }>( + `SELECT l.headword FROM lemma l + WHERE l.source NOT IN ('curated','grammar','sfx','curriculum','custom','sentence') + AND l.gloss_en <> '' AND l.headword NOT IN + (SELECT headword FROM lemma WHERE source IN ('curated','grammar','sfx','curriculum','custom')) + ORDER BY l.freq_rank LIMIT 1`, + ); + const word = row!.headword; + const infos = await wordInfo(db, [word, "뷁뷁"]); + expect(infos.get(word)).toMatchObject({ state: "gloss", inDeck: false }); + expect(infos.get("뷁뷁")).toMatchObject({ state: "unknown", inDeck: false, gloss: null }); + }); +}); + +describe("adding a looked-up word", () => { + it("adds the dictionary word, and it then counts as his — in the deck and underlined new", async () => { + const row = await db.get<{ headword: string }>( + `SELECT l.headword FROM lemma l + WHERE l.source NOT IN ('curated','grammar','sfx','curriculum','custom','sentence') + AND l.gloss_en <> '' AND l.headword NOT IN + (SELECT headword FROM lemma WHERE source IN ('curated','grammar','sfx','curriculum','custom')) + ORDER BY l.freq_rank LIMIT 1 OFFSET 3`, + ); + const word = row!.headword; + const before = (await wordInfo(db, [word])).get(word)!; + const add = wordToAdd(before); + expect(add.headword).toBe(word); + + await editAddCustomWord(db, add); + const after = (await wordInfo(db, [word])).get(word)!; + expect(after).toMatchObject({ state: "new", inDeck: true }); + expect((await deck(db)).some((e) => e.headword === word)).toBe(true); + }); + + it("adds a form nothing knows as he met it", () => { + expect( + wordToAdd({ token: "뷁다", state: "unknown", gloss: null, head: null, inDeck: false, lemma: null }), + ).toEqual({ headword: "뷁다", pos: "verb", gloss: "—" }); + }); +}); + +describe("English → 한국어", () => { + const index = buildEnIndex([ + { ko: "형", gloss: "older brother (said by a man)", note: "noun", pos: "noun", inDeck: true }, + { ko: "크다", gloss: "to be big", note: "adj", pos: "adj", inDeck: true }, + { ko: "단어", gloss: "word", note: "noun", pos: "noun", inDeck: true }, + { ko: "책상", gloss: "desk; table", note: "noun", pos: "noun", inDeck: true }, + ]); + + it("tries the longest phrase around the word first", () => { + const words = "my older brother is tall".split(" "); + expect(enLookup(index, words, 2).map((e) => e.ko)).toEqual(["형"]); + }); + + it("finds a word inside a gloss, a clause of one, and a plural made singular", () => { + expect(enLookup(index, ["big"], 0).map((e) => e.ko)).toEqual(["크다"]); + expect(enLookup(index, ["table"], 0).map((e) => e.ko)).toEqual(["책상"]); + expect(enLookup(index, ["the", "words"], 1).map((e) => e.ko)).toEqual(["단어"]); + }); + + it("never offers the small words", () => { + expect(isLookupWord("the")).toBe(false); + expect(isLookupWord("a")).toBe(false); + expect(isLookupWord("table")).toBe(true); + }); + + it("is built from the course's own material, never the frequency bands", async () => { + const shipped = await loadEnglishIndex(db); + expect(enLookup(shipped, ["water"], 0).map((e) => e.ko)).toContain("물"); + + const course = new Set( + ( + await db.all<{ headword: string }>( + `SELECT headword FROM lemma + WHERE source IN ('curated', 'grammar', 'sentence', 'sfx', 'curriculum', 'custom')`, + ) + ).map((r) => r.headword), + ); + const indexed = new Set([...shipped.values()].flat().map((e) => e.ko)); + expect(indexed.size).toBeGreaterThan(500); + expect([...indexed].filter((ko) => !course.has(ko))).toEqual([]); + }); +});