feat(tutor): look any word up — the underline, the popover, the word list
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) <noreply@anthropic.com>
This commit is contained in:
@@ -53,8 +53,15 @@ function toCard(row: Record<string, unknown>): Card | null {
|
|||||||
// every word a unit introduces is a card, because the recall evidence and
|
// every word a unit introduces is a card, because the recall evidence and
|
||||||
// the phase review both hang off one.
|
// the phase review both hang off one.
|
||||||
const REVIEWABLE = "('curated', 'sfx', 'grammar', 'custom', 'curriculum')";
|
const REVIEWABLE = "('curated', 'sfx', 'grammar', 'custom', 'curriculum')";
|
||||||
|
const REVIEWABLE_SOURCES = new Set(["curated", "sfx", "grammar", "custom", "curriculum"]);
|
||||||
const SENTENCE_SOURCE = "('sentence')";
|
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 {
|
export interface DeckOptions {
|
||||||
/** Mix glossed sentence chunks in, per the session preference. */
|
/** Mix glossed sentence chunks in, per the session preference. */
|
||||||
sentences?: boolean;
|
sentences?: boolean;
|
||||||
@@ -75,7 +82,8 @@ export const MY_UNITS_MIN = 20;
|
|||||||
* them a word added from a lesson was never reviewed again.
|
* them a word added from a lesson was never reviewed again.
|
||||||
*/
|
*/
|
||||||
export function isMine(e: Pick<DeckEntry, "source" | "unitId">, p: ProgressState): boolean {
|
export function isMine(e: Pick<DeckEntry, "source" | "unitId">, 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;
|
if (!e.unitId) return false;
|
||||||
const at = unitIndex(e.unitId);
|
const at = unitIndex(e.unitId);
|
||||||
return at >= 0 && (Boolean(p.done[e.unitId]) || at <= unitIndex(p.current));
|
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 {
|
function sourceClause(opts: DeckOptions): string {
|
||||||
if (opts.only === "sentences") return `l.source IN ${SENTENCE_SOURCE}`;
|
if (opts.only === "sentences") return `l.source IN ${SENTENCE_SOURCE}`;
|
||||||
return opts.sentences
|
const words = `l.source IN ${REVIEWABLE} OR ${ADDED}`;
|
||||||
? `l.source IN ${REVIEWABLE} OR l.source IN ${SENTENCE_SOURCE}`
|
return opts.sentences ? `${words} OR l.source IN ${SENTENCE_SOURCE}` : words;
|
||||||
: `l.source IN ${REVIEWABLE}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deck(db: Db, opts: DeckOptions = {}): Promise<DeckEntry[]> {
|
export async function deck(db: Db, opts: DeckOptions = {}): Promise<DeckEntry[]> {
|
||||||
|
|||||||
138
app/src/domain/english.ts
Normal file
138
app/src/domain/english.ts
Normal file
@@ -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<string, EnEntry[]>;
|
||||||
|
|
||||||
|
/** 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<string>();
|
||||||
|
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<string, string> = {
|
||||||
|
custom: "your own word",
|
||||||
|
sentence: "seen in a sentence",
|
||||||
|
sfx: "의성어 · 의태어",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadEnglishIndex(db: Db): Promise<EnIndex> {
|
||||||
|
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",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ export function lexicon(): Lexicon {
|
|||||||
const MAX_IN = 900; // Android's bound-parameter ceiling; see db/writes.ts.
|
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. */
|
/** Every dictionary headword each form is a surface of, in one pass. */
|
||||||
async function dictionaryHeads(db: Db, forms: string[]): Promise<Map<string, string[]>> {
|
export async function dictionaryHeads(db: Db, forms: string[]): Promise<Map<string, string[]>> {
|
||||||
const out = new Map<string, string[]>();
|
const out = new Map<string, string[]>();
|
||||||
for (let i = 0; i < forms.length; i += MAX_IN) {
|
for (let i = 0; i < forms.length; i += MAX_IN) {
|
||||||
const batch = forms.slice(i, i + MAX_IN);
|
const batch = forms.slice(i, i + MAX_IN);
|
||||||
|
|||||||
142
app/src/domain/words.ts
Normal file
142
app/src/domain/words.ts
Normal file
@@ -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<T>(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<string>,
|
||||||
|
declared: WordEntry[] | null = null,
|
||||||
|
): Promise<Map<string, WordInfo>> {
|
||||||
|
const forms = [...new Set(tokens)];
|
||||||
|
const out = new Map<string, WordInfo>();
|
||||||
|
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<string, string[]>();
|
||||||
|
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<string, Card | null>();
|
||||||
|
const words = [...new Set([...candidates.values()].flat())];
|
||||||
|
for (const batch of batches(words)) {
|
||||||
|
const rows = await db.all<Card & { headword: string; carded: number | null }>(
|
||||||
|
`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<string, WordInfo["lemma"]>();
|
||||||
|
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 || "—",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,18 +8,20 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import type { GlossBlock as Block, GlossPart, GlossRole } from "@lib/blocks.js";
|
import type { GlossBlock as Block, GlossPart, GlossRole } from "@lib/blocks.js";
|
||||||
import { ROLE_STYLES, isRole, legendFor } from "./roles.js";
|
import { ROLE_STYLES, isRole, legendFor } from "./roles.js";
|
||||||
|
import { KoText } from "./lookup.js";
|
||||||
import "./gloss.css";
|
import "./gloss.css";
|
||||||
|
|
||||||
/** Wrap the LAST occurrence of the highlight, which is where a suffix sits. */
|
/** Wrap the LAST occurrence of the highlight, which is where a suffix sits. */
|
||||||
function withHighlight(ko: string, highlight: string) {
|
function withHighlight(ko: string, highlight: string) {
|
||||||
if (!highlight) return ko;
|
const at = highlight ? ko.lastIndexOf(highlight) : -1;
|
||||||
const at = ko.lastIndexOf(highlight);
|
if (at < 0) return <KoText text={ko} />;
|
||||||
if (at < 0) return ko;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{ko.slice(0, at)}
|
<KoText text={ko.slice(0, at)} />
|
||||||
<em>{highlight}</em>
|
<em>
|
||||||
{ko.slice(at + highlight.length)}
|
<KoText text={highlight} />
|
||||||
|
</em>
|
||||||
|
<KoText text={ko.slice(at + highlight.length)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
- a line opening with ✓ or ✗ is a marked answer, and gets the colour. */
|
- a line opening with ✓ or ✗ is a marked answer, and gets the colour. */
|
||||||
|
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
import { KoText } from "./lookup.js";
|
||||||
|
|
||||||
const KOREAN = /[가-힣]/g;
|
const KOREAN = /[가-힣]/g;
|
||||||
const PUNCT = /[\s.,!?·…"'“”()[\]:;~-]/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.
|
learn:*" kept its asterisks, and a "---" divider showed as three dashes.
|
||||||
Degrading gracefully costs little and keeps a lesson readable. */
|
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) {
|
function inline(text: string) {
|
||||||
return text.split(/(\*\*[^*]+\*\*|\*[^*\n]+\*)/g).map((part, i) => {
|
return text.split(/(\*\*[^*]+\*\*|\*[^*\n]+\*)/g).map((part, i) => {
|
||||||
if (part.startsWith("**") && part.endsWith("**") && part.length > 4)
|
if (part.startsWith("**") && part.endsWith("**") && part.length > 4)
|
||||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
return (
|
||||||
|
<strong key={i}>
|
||||||
|
<KoText text={part.slice(2, -2)} />
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
if (part.startsWith("*") && part.endsWith("*") && part.length > 2)
|
if (part.startsWith("*") && part.endsWith("*") && part.length > 2)
|
||||||
return <em key={i}>{part.slice(1, -1)}</em>;
|
return (
|
||||||
return <Fragment key={i}>{part}</Fragment>;
|
<em key={i}>
|
||||||
|
<KoText text={part.slice(1, -1)} />
|
||||||
|
</em>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Fragment key={i}>
|
||||||
|
<KoText text={part} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { answerText } from "@lib/blocks.js";
|
|||||||
import { recallLetterBlock } from "../../domain/letters.js";
|
import { recallLetterBlock } from "../../domain/letters.js";
|
||||||
import { useComposer } from "../keyboard/Keyboard.js";
|
import { useComposer } from "../keyboard/Keyboard.js";
|
||||||
import { useFields, type KeyField } from "./fields.js";
|
import { useFields, type KeyField } from "./fields.js";
|
||||||
|
import { EnText, KoText, useChipGloss } from "./lookup.js";
|
||||||
import "./task.css";
|
import "./task.css";
|
||||||
|
|
||||||
/* A shuffle seed from the turn's id — a string now, see db/ids.ts. */
|
/* 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}`;
|
const id = `${turnId}:${i}`;
|
||||||
return (
|
return (
|
||||||
<div className="ti-row" key={i} data-active={answering && activeId === id ? "1" : undefined}>
|
<div className="ti-row" key={i} data-active={answering && activeId === id ? "1" : undefined}>
|
||||||
<span className="q ko">{it.q}</span>
|
<span className="q ko">
|
||||||
|
<KoText text={it.q} />
|
||||||
|
</span>
|
||||||
<AnswerField
|
<AnswerField
|
||||||
id={id}
|
id={id}
|
||||||
script="en"
|
script="en"
|
||||||
@@ -186,7 +189,7 @@ function Recall({ task, turnId, answers, update, disabled, onEnter }: FieldsProp
|
|||||||
return (
|
return (
|
||||||
<div className="ti-row recall" key={i} data-active={answering && activeId === id ? "1" : undefined}>
|
<div className="ti-row recall" key={i} data-active={answering && activeId === id ? "1" : undefined}>
|
||||||
<span className="q">
|
<span className="q">
|
||||||
{it.q}
|
<EnText text={it.q} />
|
||||||
{it.hint && <span className="rc-hint"> · {it.hint}</span>}
|
{it.hint && <span className="rc-hint"> · {it.hint}</span>}
|
||||||
</span>
|
</span>
|
||||||
<AnswerField
|
<AnswerField
|
||||||
@@ -278,7 +281,10 @@ function Match({ task, turnId, done, setDone, selected, setSelected, disabled }:
|
|||||||
<div className="mt-pairs">
|
<div className="mt-pairs">
|
||||||
{done.map((p, i) => (
|
{done.map((p, i) => (
|
||||||
<span className="mt-pair" key={`${p.left}-${p.right}`}>
|
<span className="mt-pair" key={`${p.left}-${p.right}`}>
|
||||||
<span className="ko">{task.pairs[p.left]!.ko}</span> = {task.pairs[p.right]!.gloss}
|
<span className="ko">
|
||||||
|
<KoText text={task.pairs[p.left]!.ko} />
|
||||||
|
</span>{" "}
|
||||||
|
= <EnText text={task.pairs[p.right]!.gloss} />
|
||||||
<button
|
<button
|
||||||
aria-label={`Undo ${task.pairs[p.left]!.ko}`}
|
aria-label={`Undo ${task.pairs[p.left]!.ko}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -338,7 +344,9 @@ function Build({ task, turnId, placed, setPlaced, disabled }: {
|
|||||||
<div className="bd">
|
<div className="bd">
|
||||||
{task.items.map((it, i) => (
|
{task.items.map((it, i) => (
|
||||||
<div className="bd-item" key={i}>
|
<div className="bd-item" key={i}>
|
||||||
<div className="bd-en">{it.en}</div>
|
<div className="bd-en">
|
||||||
|
<EnText text={it.en} />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="bd-slot"
|
className="bd-slot"
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
@@ -396,7 +404,9 @@ function Choice({ task, picks, setPicks, disabled }: {
|
|||||||
<div className="ch">
|
<div className="ch">
|
||||||
{task.items.map((it, i) => (
|
{task.items.map((it, i) => (
|
||||||
<div className="ch-item" key={i}>
|
<div className="ch-item" key={i}>
|
||||||
<div className="ch-q ko">{it.q}</div>
|
<div className="ch-q ko">
|
||||||
|
<EnText text={it.q} />
|
||||||
|
</div>
|
||||||
<div className="ch-opts">
|
<div className="ch-opts">
|
||||||
{it.options.map((opt, j) => (
|
{it.options.map((opt, j) => (
|
||||||
<button
|
<button
|
||||||
@@ -440,6 +450,10 @@ export function TaskHost({
|
|||||||
spent a turn — and a round of the tutor's attention — on saying no. */
|
spent a turn — and a round of the tutor's attention — on saying no. */
|
||||||
const [skipped, setSkipped] = useState(false);
|
const [skipped, setSkipped] = useState(false);
|
||||||
const root = useRef<HTMLDivElement>(null);
|
const root = useRef<HTMLDivElement>(null);
|
||||||
|
/** 힌트: a tap on a chip glosses it instead of answering. */
|
||||||
|
const [hint, setHint] = useState(false);
|
||||||
|
const chips = useChipGloss(hint);
|
||||||
|
const holdable = task.type === "match" || task.type === "build" || task.type === "choice";
|
||||||
|
|
||||||
const update = (i: number, fn: (prev: string) => string) =>
|
const update = (i: number, fn: (prev: string) => string) =>
|
||||||
setAnswers((prev) => {
|
setAnswers((prev) => {
|
||||||
@@ -520,10 +534,20 @@ export function TaskHost({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="task" data-spent={spent} ref={root}>
|
<div className="task" data-spent={spent} ref={root} {...(holdable && open ? chips.root : {})}>
|
||||||
<div className="task-h">
|
<div className="task-h">
|
||||||
<span className="eyebrow">연습 · {task.type}</span>
|
<span className="eyebrow">연습 · {task.type}</span>
|
||||||
<span className="hint">{LABEL[task.type]}</span>
|
<span className="hint">{LABEL[task.type]}</span>
|
||||||
|
{holdable && open && (
|
||||||
|
<button
|
||||||
|
className="hintbtn ko"
|
||||||
|
aria-pressed={hint}
|
||||||
|
title="Tap a chip to see what it means — or hold it"
|
||||||
|
onClick={() => setHint((h) => !h)}
|
||||||
|
>
|
||||||
|
{hint ? "힌트 켜짐" : "힌트"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="task-b">
|
<div className="task-b">
|
||||||
|
|||||||
@@ -21,7 +21,15 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||||
import { useStore } from "../../state/store.js";
|
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 { parseMessage } from "../../domain/gloss.js";
|
||||||
import {
|
import {
|
||||||
gateFor,
|
gateFor,
|
||||||
@@ -36,7 +44,9 @@ import type { FocusMode } from "../../domain/gate.js";
|
|||||||
import { currentUnit, noteAnswer } from "../../domain/progress.js";
|
import { currentUnit, noteAnswer } from "../../domain/progress.js";
|
||||||
import { makeStubTutor, SampleError, type Sample, type StubWord } from "../../domain/stub-tutor.js";
|
import { makeStubTutor, SampleError, type Sample, type StubWord } from "../../domain/stub-tutor.js";
|
||||||
import { makeRemoteTutor } from "../../domain/tutor-client.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 { applyReply, readRecent, runTurn } from "../../domain/turn.js";
|
||||||
import { coverage } from "../../domain/ledger.js";
|
import { coverage } from "../../domain/ledger.js";
|
||||||
import type { Retry } from "../../domain/prompt-tail.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 { RoadStrip } from "./RoadStrip.js";
|
||||||
import { AnswerBar, Composer } from "./Composer.js";
|
import { AnswerBar, Composer } from "./Composer.js";
|
||||||
import { FieldsContext, liveAnswerFields, type Fields, type KeyField } from "./fields.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 { Keyboard } from "../keyboard/Keyboard.js";
|
||||||
import { RouteHead, useRouteActive } from "../shell/Route.js";
|
import { RouteHead, useRouteActive } from "../shell/Route.js";
|
||||||
import { useLayer } from "../shell/router.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. */
|
/** Flagged words per turn id, so the flag stays under its own message. */
|
||||||
const [flags, setFlags] = useState<Record<string, string[]>>({});
|
const [flags, setFlags] = useState<Record<string, string[]>>({});
|
||||||
const [roadOpen, setRoadOpen] = useState(false);
|
const [roadOpen, setRoadOpen] = useState(false);
|
||||||
|
/** The word popover, and what the word it is open on is to him. */
|
||||||
|
const [pop, setPop] = useState<PopTarget | null>(null);
|
||||||
|
const [popInfo, setPopInfo] = useState<WordInfo | null>(null);
|
||||||
|
/** Every Korean word in the transcript, for its underline. */
|
||||||
|
const [words, setWords] = useState<Map<string, WordInfo>>(new Map());
|
||||||
|
const [enIndex, setEnIndex] = useState<EnIndex | null>(null);
|
||||||
|
/** Added from an English popover this session, for its ✓. */
|
||||||
|
const [addedEn, setAddedEn] = useState<Set<string>>(new Set());
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [detent, setDetent] = useState<Detent>("closed");
|
const [detent, setDetent] = useState<Detent>("closed");
|
||||||
|
|
||||||
@@ -556,6 +576,109 @@ export function TutorTab() {
|
|||||||
[turns],
|
[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<Lookup>(
|
||||||
|
() => ({
|
||||||
|
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
|
/* parseMessage() re-parsed the entire partial reply on every token, and
|
||||||
on every unrelated re-render of the lesson. */
|
on every unrelated re-render of the lesson. */
|
||||||
const streamingBody = useMemo(() => {
|
const streamingBody = useMemo(() => {
|
||||||
@@ -717,6 +840,16 @@ export function TutorTab() {
|
|||||||
setDetent("half");
|
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(() => {
|
useEffect(() => {
|
||||||
if (detent !== "closed" || !keepAnswering.current) return;
|
if (detent !== "closed" || !keepAnswering.current) return;
|
||||||
const field = resumeField.current;
|
const field = resumeField.current;
|
||||||
@@ -770,6 +903,8 @@ export function TutorTab() {
|
|||||||
onReveal: reveal,
|
onReveal: reveal,
|
||||||
query: railQuery,
|
query: railQuery,
|
||||||
onQuery: setRailQuery,
|
onQuery: setRailQuery,
|
||||||
|
infoOf: (ko: string) => words.get(ko),
|
||||||
|
onAdd: addWord,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -856,167 +991,216 @@ export function TutorTab() {
|
|||||||
</div>
|
</div>
|
||||||
</Pop>
|
</Pop>
|
||||||
|
|
||||||
<FieldsContext.Provider value={fields}>
|
<Pop
|
||||||
<div className="lesson-wrap">
|
id="word"
|
||||||
<div
|
open={pop !== null && active}
|
||||||
className="chatcol"
|
onClose={() => setPop(null)}
|
||||||
onFocusCapture={(e) => {
|
anchor={pop?.anchor ?? null}
|
||||||
// Typing an answer brings the sheet down to peek: what is being
|
label={pop?.kind === "en" ? "English to Korean" : "Word"}
|
||||||
// answered is never behind it.
|
>
|
||||||
const t = e.target as HTMLElement;
|
{pop?.kind === "ko" && (
|
||||||
if (keepAnswering.current) return;
|
<KoPop
|
||||||
if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
|
token={pop.token}
|
||||||
setDetent("peek");
|
info={popInfo}
|
||||||
}
|
stray={strays.includes(pop.token)}
|
||||||
|
onAdd={() => {
|
||||||
|
if (popInfo) void addWord(wordToAdd(popInfo));
|
||||||
|
setPop(null);
|
||||||
}}
|
}}
|
||||||
onInputCapture={() => {
|
onAsk={() => {
|
||||||
if (answering) recount();
|
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?`,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
onFind={() => {
|
||||||
<RoadStrip
|
setRailQuery(pop.token);
|
||||||
open={roadOpen}
|
setPop(null);
|
||||||
onClose={() => setRoadOpen(false)}
|
openList();
|
||||||
busy={busy}
|
}}
|
||||||
onUnitChange={(id, how) => {
|
onList={() => {
|
||||||
opened.current = true;
|
setRailQuery("");
|
||||||
const u = UNITS.find((x) => x.id === id);
|
setPop(null);
|
||||||
if (!u) return;
|
openList();
|
||||||
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.`,
|
{pop?.kind === "en" && (
|
||||||
);
|
<EnPop
|
||||||
}}
|
word={pop.word}
|
||||||
/>
|
hits={pop.hits}
|
||||||
|
added={addedEn}
|
||||||
|
onAdd={(e) => {
|
||||||
|
setAddedEn((a) => new Set(a).add(e.ko));
|
||||||
|
void addWord({ headword: e.ko, pos: e.pos, gloss: e.gloss });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pop>
|
||||||
|
|
||||||
<div className="chat-log" ref={log} onScroll={onLogScroll}>
|
<LookupContext.Provider value={lookup}>
|
||||||
{parsedTurns.map(({ turn: t, parsed }, i) => {
|
<FieldsContext.Provider value={fields}>
|
||||||
const you = t.role === "user";
|
<div className="lesson-wrap">
|
||||||
const body = parsed ? parsed.body : ownWords(t.body);
|
<div
|
||||||
return (
|
className="chatcol"
|
||||||
<div className={`msg${you ? " you" : ""}`} key={t.id}>
|
onFocusCapture={(e) => {
|
||||||
<span className="who ko">{you ? "나" : "선생님"}</span>
|
// Typing an answer brings the sheet down to peek: what is being
|
||||||
{/* A turn can be nothing but blocks — some models write no
|
// answered is never behind it.
|
||||||
prose around an exercise at all. Rendering the bubble
|
const t = e.target as HTMLElement;
|
||||||
anyway left an empty box above it. */}
|
if (keepAnswering.current) return;
|
||||||
{(body.trim() || parsed?.gloss) && (
|
if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
|
||||||
|
setDetent("peek");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInputCapture={() => {
|
||||||
|
if (answering) recount();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RoadStrip
|
||||||
|
open={roadOpen}
|
||||||
|
onClose={() => 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.`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="chat-log" ref={log} onScroll={onLogScroll}>
|
||||||
|
{parsedTurns.map(({ turn: t, parsed }, i) => {
|
||||||
|
const you = t.role === "user";
|
||||||
|
const body = parsed ? parsed.body : ownWords(t.body);
|
||||||
|
return (
|
||||||
|
<div className={`msg${you ? " you" : ""}`} key={t.id}>
|
||||||
|
<span className="who ko">{you ? "나" : "선생님"}</span>
|
||||||
|
{/* 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) && (
|
||||||
|
<div className="bubble">
|
||||||
|
<MessageBody text={body} />
|
||||||
|
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flags[t.id] && (
|
||||||
|
<p className="msg-flag">
|
||||||
|
Not taught yet: <span className="ko">{flags[t.id]!.join(" · ")}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<TaskHost
|
||||||
|
task={parsed.task}
|
||||||
|
words={parsed.words}
|
||||||
|
turnId={t.id}
|
||||||
|
lookups={[...revealed]}
|
||||||
|
disabled={busy || !isLast(i)}
|
||||||
|
spent={!isLast(i)}
|
||||||
|
onSubmit={(message) => void send(message, { lookups: [...revealed] })}
|
||||||
|
submitRef={isLast(i) ? submitTask : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<div className="msg" key="pending">
|
||||||
|
<span className="who ko">선생님</span>
|
||||||
|
{/* 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() === "" ? (
|
||||||
|
<div className="bubble dots" aria-label="선생님 is writing">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="bubble">
|
<div className="bubble">
|
||||||
<MessageBody text={body} />
|
<MessageBody text={streamingBody} />
|
||||||
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{flags[t.id] && (
|
|
||||||
<p className="msg-flag">
|
|
||||||
Not taught yet: <span className="ko">{flags[t.id]!.join(" · ")}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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 && (
|
|
||||||
<TaskHost
|
|
||||||
task={parsed.task}
|
|
||||||
words={parsed.words}
|
|
||||||
turnId={t.id}
|
|
||||||
lookups={[...revealed]}
|
|
||||||
disabled={busy || !isLast(i)}
|
|
||||||
spent={!isLast(i)}
|
|
||||||
onSubmit={(message) => void send(message, { lookups: [...revealed] })}
|
|
||||||
submitRef={isLast(i) ? submitTask : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
|
|
||||||
{/* One slot for the turn in flight, keyed so the node survives
|
<Composer
|
||||||
the change from waiting to streaming. As two sibling
|
unit={unit}
|
||||||
conditionals the dots unmounted and the text mounted in
|
busy={busy}
|
||||||
their place, which read as a blink at the moment the first
|
notStarted={notStarted}
|
||||||
token arrived. */}
|
draft={draft}
|
||||||
{busy && (
|
setDraft={setDraft}
|
||||||
<div className="msg" key="pending">
|
keyboard={keyboard}
|
||||||
<span className="who ko">선생님</span>
|
onToggleKeyboard={() => toggleKeyboard(false)}
|
||||||
{/* Keep the dots up while the reply so far is only block
|
messageField={messageField}
|
||||||
markup: there is genuinely nothing to read yet, and an
|
note={note}
|
||||||
empty bubble reads as a failure rather than as waiting. */}
|
onSend={(text) => void send(text, { restore: true })}
|
||||||
{streamingBody.trim() === "" ? (
|
onStop={() => abort.current?.abort()}
|
||||||
<div className="bubble dots" aria-label="선생님 is writing">
|
>
|
||||||
<i />
|
{answering && (
|
||||||
<i />
|
<AnswerBar
|
||||||
<i />
|
position={position}
|
||||||
</div>
|
keyboard={keyboard}
|
||||||
) : (
|
wordsOpen={detent !== "closed"}
|
||||||
<div className="bubble">
|
canPrev={onField && at > 0}
|
||||||
<MessageBody text={streamingBody} />
|
canNext={onField && at < answerFields.length - 1}
|
||||||
</div>
|
busy={busy}
|
||||||
)}
|
onDone={() => {
|
||||||
</div>
|
(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 && (
|
||||||
|
<Keyboard
|
||||||
|
composer={target.composer}
|
||||||
|
onChange={target.apply}
|
||||||
|
target={target.kind === "message" ? "message" : "answer"}
|
||||||
|
onKey={() => {
|
||||||
|
const el = target.el();
|
||||||
|
if (el && document.activeElement !== el) el.focus({ preventScroll: true });
|
||||||
|
}}
|
||||||
|
onDismiss={() => setKeyboard(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Composer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Composer
|
{wide && (
|
||||||
unit={unit}
|
<aside className="railcol" aria-label="Word list">
|
||||||
busy={busy}
|
<RailPanel {...railProps} />
|
||||||
notStarted={notStarted}
|
</aside>
|
||||||
draft={draft}
|
)}
|
||||||
setDraft={setDraft}
|
|
||||||
keyboard={keyboard}
|
|
||||||
onToggleKeyboard={() => toggleKeyboard(false)}
|
|
||||||
messageField={messageField}
|
|
||||||
note={note}
|
|
||||||
onSend={(text) => void send(text, { restore: true })}
|
|
||||||
onStop={() => abort.current?.abort()}
|
|
||||||
>
|
|
||||||
{answering && (
|
|
||||||
<AnswerBar
|
|
||||||
position={position}
|
|
||||||
keyboard={keyboard}
|
|
||||||
wordsOpen={detent !== "closed"}
|
|
||||||
canPrev={onField && at > 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 && (
|
|
||||||
<Keyboard
|
|
||||||
composer={target.composer}
|
|
||||||
onChange={target.apply}
|
|
||||||
target={target.kind === "message" ? "message" : "answer"}
|
|
||||||
onKey={() => {
|
|
||||||
const el = target.el();
|
|
||||||
if (el && document.activeElement !== el) el.focus({ preventScroll: true });
|
|
||||||
}}
|
|
||||||
onDismiss={() => setKeyboard(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Composer>
|
|
||||||
</div>
|
</div>
|
||||||
|
</FieldsContext.Provider>
|
||||||
{wide && (
|
</LookupContext.Provider>
|
||||||
<aside className="railcol" aria-label="Word list">
|
|
||||||
<RailPanel {...railProps} />
|
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FieldsContext.Provider>
|
|
||||||
|
|
||||||
{!wide && active && (
|
{!wide && active && (
|
||||||
<WordSheet detent={detent} onDetent={setDetent}>
|
<WordSheet detent={detent} onDetent={setDetent}>
|
||||||
|
|||||||
121
app/src/ui/tutor/WordPop.tsx
Normal file
121
app/src/ui/tutor/WordPop.tsx
Normal file
@@ -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 (
|
||||||
|
<div onPointerDown={keepFocus}>
|
||||||
|
<div className="pop-b">
|
||||||
|
<span className="pop-k ko">{token}</span>
|
||||||
|
{gloss ? (
|
||||||
|
<span className="pop-m">{gloss}</span>
|
||||||
|
) : (
|
||||||
|
<span className="pop-m dim">
|
||||||
|
{!info
|
||||||
|
? "…"
|
||||||
|
: stray
|
||||||
|
? "선생님 used this without teaching it — ask him"
|
||||||
|
: "not in the word list yet"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{notes.length > 0 && <span className="pop-n">{notes.join(" · ")}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="pop-f">
|
||||||
|
{gloss ? (
|
||||||
|
<button className="btn" disabled={!info || info.inDeck} onClick={onAdd}>
|
||||||
|
{info?.inDeck ? "✓ in your deck" : "+ Add to deck"}
|
||||||
|
</button>
|
||||||
|
) : stray ? (
|
||||||
|
<button className="btn" onClick={onAsk}>
|
||||||
|
Ask 선생님
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn" disabled={!info} onClick={onFind}>
|
||||||
|
Search <span className="ko">단어</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn" onClick={onList}>
|
||||||
|
Word list
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnPop({
|
||||||
|
word,
|
||||||
|
hits,
|
||||||
|
added,
|
||||||
|
onAdd,
|
||||||
|
}: {
|
||||||
|
word: string;
|
||||||
|
hits: EnEntry[];
|
||||||
|
/** Added from this popover just now. */
|
||||||
|
added: Set<string>;
|
||||||
|
onAdd: (e: EnEntry) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div onPointerDown={keepFocus}>
|
||||||
|
<div className="pop-b">
|
||||||
|
<span className="pop-n">English → 한국어</span>
|
||||||
|
<span className="pop-en">{word}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-list">
|
||||||
|
{hits.map((e) => {
|
||||||
|
const inDeck = e.inDeck || added.has(e.ko);
|
||||||
|
return (
|
||||||
|
<div className="pop-row" key={e.ko}>
|
||||||
|
<span className="k ko">{e.ko}</span>
|
||||||
|
<span className="m">
|
||||||
|
{e.gloss}
|
||||||
|
{e.note && <i>{e.note}</i>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="wr-add"
|
||||||
|
data-in={inDeck ? "1" : undefined}
|
||||||
|
disabled={inDeck}
|
||||||
|
aria-label={inDeck ? `${e.ko} is in your deck` : `Add ${e.ko} to your deck`}
|
||||||
|
title={inDeck ? "in your deck" : "add to your deck"}
|
||||||
|
onClick={() => onAdd(e)}
|
||||||
|
>
|
||||||
|
{inDeck ? "✓" : "+"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,8 +23,10 @@ import { useEffect, useState, type ReactNode } from "react";
|
|||||||
import type { WordEntry } from "@lib/blocks.js";
|
import type { WordEntry } from "@lib/blocks.js";
|
||||||
import { useStore } from "../../state/store.js";
|
import { useStore } from "../../state/store.js";
|
||||||
import { editPeek } from "../../db/writes.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 { ensureReferenceBand } from "../../domain/dictionary.js";
|
||||||
|
import { glossMany } from "../../domain/resolver.js";
|
||||||
|
import type { WordInfo } from "../../domain/words.js";
|
||||||
import "./rail.css";
|
import "./rail.css";
|
||||||
|
|
||||||
export interface RailWord {
|
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
|
* 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
|
* win; scanned tokens are glossed by the one resolver the popover and the
|
||||||
* shown blank.
|
* gate use, and those it cannot gloss are dropped rather than shown blank.
|
||||||
*/
|
*/
|
||||||
export async function collectWords(
|
export async function collectWords(
|
||||||
db: ReturnType<typeof useStore>["db"],
|
db: ReturnType<typeof useStore>["db"],
|
||||||
@@ -59,17 +61,12 @@ export async function collectWords(
|
|||||||
|
|
||||||
const tokens = koreanTokens(text).filter((t) => !seen.has(t));
|
const tokens = koreanTokens(text).filter((t) => !seen.has(t));
|
||||||
if (tokens.length) {
|
if (tokens.length) {
|
||||||
const found = await lookupMany(db, tokens);
|
const found = await glossMany(db, tokens);
|
||||||
for (const t of tokens) {
|
for (const t of tokens) {
|
||||||
const hit = found.get(t);
|
const hit = found.get(t);
|
||||||
if (!hit || seen.has(t)) continue;
|
if (!hit?.gloss || seen.has(t)) continue;
|
||||||
seen.add(t);
|
seen.add(t);
|
||||||
out.push({
|
out.push({ ko: t, gloss: hit.gloss, note: hit.note, fromTutor: false });
|
||||||
ko: t,
|
|
||||||
gloss: hit.glossEn,
|
|
||||||
note: hit.analysis && hit.headword !== t ? hit.analysis : "",
|
|
||||||
fromTutor: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,11 +85,48 @@ export interface RailPanelProps {
|
|||||||
titleId?: string;
|
titleId?: string;
|
||||||
/** Buttons at the end of the heading row — the sheet's expand and close. */
|
/** Buttons at the end of the heading row — the sheet's expand and close. */
|
||||||
actions?: ReactNode;
|
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 (
|
||||||
|
<button
|
||||||
|
className="wr-add"
|
||||||
|
data-in={inDeck ? "1" : undefined}
|
||||||
|
disabled={inDeck}
|
||||||
|
title={inDeck ? "in your deck" : "add to your deck"}
|
||||||
|
aria-label={inDeck ? `${ko} is in your deck` : `Add ${ko} to your deck`}
|
||||||
|
onClick={onAdd}
|
||||||
|
>
|
||||||
|
{inDeck ? "✓" : "+"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RailPanel({
|
||||||
|
words,
|
||||||
|
revealed,
|
||||||
|
onReveal,
|
||||||
|
query,
|
||||||
|
onQuery,
|
||||||
|
titleId,
|
||||||
|
actions,
|
||||||
|
infoOf,
|
||||||
|
onAdd,
|
||||||
|
}: RailPanelProps) {
|
||||||
const { db, prefs, setPref } = useStore();
|
const { db, prefs, setPref } = useStore();
|
||||||
const [results, setResults] = useState<Entry[]>([]);
|
const [results, setResults] = useState<Entry[]>([]);
|
||||||
|
/** Added from this list just now: a search row cannot tell otherwise. */
|
||||||
|
const [added, setAdded] = useState<Set<string>>(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;
|
const searching = query.trim().length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -169,6 +203,11 @@ export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId,
|
|||||||
{r.glossEn}
|
{r.glossEn}
|
||||||
<i>{r.pos}</i>
|
<i>{r.pos}</i>
|
||||||
</span>
|
</span>
|
||||||
|
<AddButton
|
||||||
|
ko={r.headword}
|
||||||
|
inDeck={DECK_SOURCES.has(r.source) || added.has(r.headword)}
|
||||||
|
onAdd={() => add({ headword: r.headword, pos: r.pos, gloss: r.glossEn })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -199,6 +238,20 @@ export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId,
|
|||||||
{w.note && <i>{w.note}</i>}
|
{w.note && <i>{w.note}</i>}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<AddButton
|
||||||
|
ko={w.ko}
|
||||||
|
inDeck={Boolean(infoOf(w.ko)?.inDeck) || added.has(w.ko)}
|
||||||
|
onAdd={() => {
|
||||||
|
const info = infoOf(w.ko);
|
||||||
|
add(
|
||||||
|
info?.lemma ?? {
|
||||||
|
headword: w.ko,
|
||||||
|
pos: /다$/.test(w.ko) ? "verb" : "noun",
|
||||||
|
gloss: w.gloss,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
203
app/src/ui/tutor/lookup.css
Normal file
203
app/src/ui/tutor/lookup.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
168
app/src/ui/tutor/lookup.tsx
Normal file
168
app/src/ui/tutor/lookup.tsx
Normal file
@@ -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<Lookup | null>(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 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={i}
|
||||||
|
className="kw"
|
||||||
|
data-s={lookup.stateOf(part)}
|
||||||
|
onPointerDown={keepFocus}
|
||||||
|
onMouseDown={keepFocus}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
lookup.openKo(e.currentTarget, part);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
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 ? <KoText key={i} text={part} /> : null;
|
||||||
|
const here = ++at;
|
||||||
|
if (!lookup.hasEnglish(words, here)) return part;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={i}
|
||||||
|
className="kw-en"
|
||||||
|
onPointerDown={keepFocus}
|
||||||
|
onMouseDown={keepFocus}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
lookup.openEn(e.currentTarget, part, words, here);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ReturnType<typeof setTimeout> | 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<HTMLButtonElement>(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<HTMLElement>(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();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
127
test/domain/words.test.ts
Normal file
127
test/domain/words.test.ts
Normal file
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user