From f49ed388f405098e0cad0725dae82f940e2da9ea Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 8 Sep 2026 19:28:04 +0200 Subject: [PATCH] feat(app): the artifact features that were never ported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six gaps the last review named, closed. FOCUS SELECTOR. FOCUS_MODES and focusLine() already existed and already fed {{FOCUS}}; nothing in the UI ever set prefs.focus, so it was permanently "auto". Now a seven-mode picker in the chat header, with a compile-time check that every listed mode exists in FOCUS_MODES — a typo would otherwise render an empty {{FOCUS}} silently. ADD YOUR OWN WORD. Custom words live in `lemma` beside the dictionary, with ids from a reserved range starting at 10,000,000. The build assigns ids sequentially from 1, so a custom word placed in that range would be overwritten the next time the band files reloaded. `lemma` is UNIQUE on (headword, pos) and the shipped dictionary is large, so "add a word" collides with an existing entry regularly — 각성 already being there is the normal case, not the exceptional one. Adding an existing word now gives it a card and says so, rather than throwing an unhandled UNIQUE violation into the console, which is what the first cut did. Its curated gloss is kept; overwriting one from a text field would be a poor trade. Only custom rows can be deleted outright. GRAMMAR NOTES. Per-point textarea, saved on blur. Shares one JSON-in-meta helper with the learned flags and the trainer score. SEEDED KNOWN WORDS. The artifact's 30-word SEED_KNOWN list, applied once after the bands load — they have to exist as lemmas to be matched. Applied through seedCard(), so updated_at stays 0: it matches the artifact's own stampInit() behaviour, and it keeps the seed invisible to sync when that lands. 47 cards, because several headwords appear as both a curated word and a sentence chunk, and he knows both. FULL RESET. Two scopes, each spelled out before the second press. Neither touches the dictionary — it is reference data, rebuildable from the assets, and wiping it would leave the app unable to gloss anything. ABOUT PANEL. lexicon.stats() was written and unused. It now reports what is loaded here against what shipped, the storage driver, and the attribution — which is a licence obligation, not decoration. Verified in a browser: focus persists across reload, notes persist, a colliding word is adopted, a new word round-trips through add and delete, and 47 cards seed secure on first run. Co-Authored-By: Claude Opus 5 --- app/src/db/writes.ts | 127 +++++++++++++++++++++++++++++++++ app/src/domain/cards.ts | 4 +- app/src/domain/notes.ts | 25 +++++++ app/src/domain/seed-known.ts | 47 ++++++++++++ app/src/state/store.tsx | 5 ++ app/src/ui/tabs/GrammarTab.tsx | 44 +++++++++--- app/src/ui/tabs/TodayTab.tsx | 114 +++++++++++++++++++++++++++++ app/src/ui/tabs/VocabTab.tsx | 72 +++++++++++++++++++ app/src/ui/tabs/grammar.css | 6 ++ app/src/ui/tabs/today.css | 25 +++++++ app/src/ui/tabs/vocab.css | 28 ++++++++ app/src/ui/tutor/TutorTab.tsx | 41 ++++++++++- app/src/ui/tutor/tutor.css | 12 ++++ 13 files changed, 537 insertions(+), 13 deletions(-) create mode 100644 app/src/domain/notes.ts create mode 100644 app/src/domain/seed-known.ts diff --git a/app/src/db/writes.ts b/app/src/db/writes.ts index 816a5c1..2fd81a3 100644 --- a/app/src/db/writes.ts +++ b/app/src/db/writes.ts @@ -181,6 +181,133 @@ export async function editPeek(db: Db, form: string): Promise { ); } +/* ═════════════════════════════════════════════════════════════════════ + CUSTOM WORDS — the learner's own additions. + + These live in `lemma` alongside the shipped dictionary, but their ids + come from a reserved range far above anything the build emits. Band ids + are assigned sequentially from 1, so a custom word placed in that range + would be silently overwritten the next time `npm run dict:build` runs and + the band files are reloaded. The reserved range is what keeps the + learner's own vocabulary from being collateral damage of a dictionary + rebuild. + ═════════════════════════════════════════════════════════════════════ */ + +/** First id available to custom words. The build never emits ids this high. */ +export const CUSTOM_LEMMA_BASE = 10_000_000; + +export interface CustomWord { + headword: string; + gloss: string; + pos: string; +} + +export interface AddedWord { + lemmaId: number; + /** False when the dictionary already had this (headword, pos). */ + created: boolean; +} + +/** + * Add a word of the learner's own. + * + * `lemma` is UNIQUE on (headword, pos), and the shipped dictionary is large — + * so "add a word" will regularly collide with one already in it. That is not + * an error and must not surface as one: the intent is "I want to study this", + * which is satisfied by giving the existing entry a card. Only a genuinely + * new word creates a row. + */ +export async function editAddCustomWord(db: Db, word: CustomWord): Promise { + const headword = word.headword.trim(); + const gloss = word.gloss.trim(); + + return db.tx(async (tx) => { + const existing = await tx.get<{ id: number }>( + "SELECT id FROM lemma WHERE headword = ? AND pos = ?", + [headword, word.pos], + ); + + if (existing) { + // Already known — just make sure it is studiable. Its gloss stays the + // dictionary's; overwriting curated content from a text field would be + // a poor trade. + await tx.run( + `INSERT INTO card (lemma_id, state, ease, interval, due, reps, lapses, updated_at) + VALUES (?, 0, 2.5, 0, 0, 0, 0, ?) + ON CONFLICT(lemma_id) DO NOTHING`, + [existing.id, now()], + ); + return { lemmaId: existing.id, created: false }; + } + + const top = await tx.get<{ id: number | null }>( + "SELECT max(id) AS id FROM lemma WHERE id >= ?", + [CUSTOM_LEMMA_BASE], + ); + const id = Math.max(CUSTOM_LEMMA_BASE, (top?.id ?? 0) + 1); + + await tx.run( + `INSERT INTO lemma (id, headword, pos, freq_rank, level, gloss_en, gloss_ko, + unit_band, source) + VALUES (?, ?, ?, NULL, NULL, ?, '', 0, 'custom')`, + [id, headword, word.pos, gloss], + ); + await tx.run("INSERT OR REPLACE INTO surface (form, lemma_id, analysis) VALUES (?, ?, ?)", [ + headword, + id, + "headword, custom", + ]); + await tx.run( + `INSERT INTO card (lemma_id, state, ease, interval, due, reps, lapses, updated_at) + VALUES (?, 0, 2.5, 0, 0, 0, 0, ?)`, + [id, now()], + ); + return { lemmaId: id, created: true }; + }); +} + +export async function editRemoveCustomWord(db: Db, lemmaId: number): Promise { + if (lemmaId < CUSTOM_LEMMA_BASE) return; // never touch shipped dictionary rows + await db.tx(async (tx) => { + await tx.run("DELETE FROM card WHERE lemma_id = ?", [lemmaId]); + await tx.run("DELETE FROM surface WHERE lemma_id = ?", [lemmaId]); + await tx.run("DELETE FROM lemma WHERE id = ?", [lemmaId]); + }); +} + +/* ═════════════════════════════════════════════════════════════════════ + RESET — deliberately destructive, so it is spelled out here rather than + assembled ad hoc at a call site. + + Neither scope touches `lemma` or `surface` for shipped words: the + dictionary is reference data, rebuildable from the assets, and wiping it + would leave the app unable to gloss anything until the bands reloaded. + ═════════════════════════════════════════════════════════════════════ */ + +export type ResetScope = "roadmap" | "everything"; + +export async function editReset(db: Db, scope: ResetScope): Promise { + await db.tx(async (tx) => { + await tx.run("DELETE FROM progress"); + await tx.run("DELETE FROM chat"); + + if (scope === "everything") { + await tx.run("DELETE FROM card"); + await tx.run("DELETE FROM study_log"); + await tx.run("DELETE FROM peek"); + await tx.run("DELETE FROM surface WHERE lemma_id >= ?", [CUSTOM_LEMMA_BASE]); + await tx.run("DELETE FROM lemma WHERE id >= ?", [CUSTOM_LEMMA_BASE]); + // Preferences, grammar flags and notes, trainer score, and the + // known-words seed marker. Device bookkeeping (schema_version, + // dict.*) is left alone — it describes this install, not the learner. + await tx.run( + `DELETE FROM meta WHERE k LIKE 'prefs.%' OR k LIKE 'grammar.%' + OR k LIKE 'trainer.%' OR k LIKE 'seed.%'`, + ); + } + }); +} + /* ═════════════════════════════════════════════════════════════════════ DICTIONARY WRITES — reference data from the shipped band files. Not user data, never synced, and rebuildable from the assets, so these diff --git a/app/src/domain/cards.ts b/app/src/domain/cards.ts index ce66c9a..b232130 100644 --- a/app/src/domain/cards.ts +++ b/app/src/domain/cards.ts @@ -42,7 +42,9 @@ function toCard(row: Record): Card | null { * The reviewable deck: curated words and sentence chunks, not the whole * dictionary. Reviewing 30,000 dictionary entries is not a study plan. */ -const REVIEWABLE = "('curated', 'sfx', 'grammar')"; +// 'custom' is here so the learner's own words are reviewable like any +// other — adding a word you cannot then study would be pointless. +const REVIEWABLE = "('curated', 'sfx', 'grammar', 'custom')"; const SENTENCE_SOURCE = "('sentence')"; export interface DeckOptions { diff --git a/app/src/domain/notes.ts b/app/src/domain/notes.ts new file mode 100644 index 0000000..77211b1 --- /dev/null +++ b/app/src/domain/notes.ts @@ -0,0 +1,25 @@ +/* Small JSON blobs kept in `meta` — grammar flags, per-point notes, the + conjugation trainer's score. Each is one row, rewritten whole. + + They are genuine user edits, so they go through editMeta() and get + stamped; the sync allowlist carries them. */ + +import type { Db } from "../db/types.js"; +import { editMeta } from "../db/writes.js"; + +export async function readJsonMeta(db: Db, key: string, fallback: T): Promise { + const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [key]); + if (!row) return fallback; + try { + return JSON.parse(row.v) as T; + } catch { + return fallback; // a corrupt value is not worth failing a tab over + } +} + +export const writeJsonMeta = (db: Db, key: string, value: unknown): Promise => + editMeta(db, key, JSON.stringify(value)); + +export const GRAMMAR_LEARNED = "grammar.learned"; +export const GRAMMAR_NOTES = "grammar.notes"; +export const TRAINER_CONJUGATION = "trainer.conjugation"; diff --git a/app/src/domain/seed-known.ts b/app/src/domain/seed-known.ts new file mode 100644 index 0000000..2b8b8db --- /dev/null +++ b/app/src/domain/seed-known.ts @@ -0,0 +1,47 @@ +/* The words the learner already had before the app existed. + + Carried over from the artifact, which pre-marked these secure on first + run so the first review session was not thirty cards he could already + read. The list is his, not a curriculum artefact — it came from the + progress summary he wrote when the artifact was built. + + Applied through seedCard(), which leaves updated_at = 0. That matters + twice over: it matches the artifact's own stampInit() behaviour, and it + keeps the seed invisible to sync — a fresh device seeding itself must + never look newer than the server's real history. */ + +import type { Db } from "../db/types.js"; +import { seedCard, seedMeta } from "../db/writes.js"; +import { markKnown } from "@lib/srs.js"; + +/** Verbatim from the artifact's SEED_KNOWN. */ +export const SEED_KNOWN = [ + "나", "저", "너", "우리", "이", "그", "뭐", "왜", "누구", "어디", + "언제", "친구", "물", "밥", "책", "학교", "가다", "오다", "먹다", "마시다", + "좋다", "싫다", "크다", "작다", "슬프다", "진짜?", "잠깐만", "안 돼", "좋아", "싫어", +] as const; + +const FLAG = "seed.known"; + +/** + * Mark the seed words secure, once. Returns how many matched a lemma — + * some are phrases the dictionary may not carry as headwords, and a miss + * is not an error. + */ +export async function seedKnownWords(db: Db, today: number): Promise { + const done = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [FLAG]); + if (done) return 0; + + const holes = SEED_KNOWN.map(() => "?").join(","); + const rows = await db.all<{ id: number }>( + `SELECT id FROM lemma WHERE headword IN (${holes}) AND source != 'custom'`, + [...SEED_KNOWN], + ); + + const card = markKnown(today); + for (const row of rows) await seedCard(db, row.id, card); + + // Bookkeeping, not a user edit: unstamped, like the cards it guards. + await seedMeta(db, FLAG, String(rows.length)); + return rows.length; +} diff --git a/app/src/state/store.tsx b/app/src/state/store.tsx index 748a568..5f34cab 100644 --- a/app/src/state/store.tsx +++ b/app/src/state/store.tsx @@ -24,6 +24,7 @@ import { bandForUnit } from "@shared/bands.mjs"; import { ensureBands, loadManifest, recordProvenance, type DictManifest } from "../domain/dictionary.js"; import { initProgress, readProgress } from "../domain/progress.js"; +import { seedKnownWords } from "../domain/seed-known.js"; import type { ProgressState } from "@lib/gate.js"; import type { FocusMode } from "../domain/gate.js"; @@ -142,6 +143,10 @@ export function StoreProvider({ const stored = await readProgress(db); await ensureBands(db, bandForUnit(stored.current)); + // Needs the band rows present to match headwords, so it runs after + // ensureBands. Seeded, so it carries no write timestamp. + await seedKnownWords(db, dayNumber()); + const rows = await db.all<{ k: string; v: string }>( "SELECT k, v FROM meta WHERE k LIKE 'prefs.%'", ); diff --git a/app/src/ui/tabs/GrammarTab.tsx b/app/src/ui/tabs/GrammarTab.tsx index ce12588..e4b4bd9 100644 --- a/app/src/ui/tabs/GrammarTab.tsx +++ b/app/src/ui/tabs/GrammarTab.tsx @@ -8,7 +8,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useStore } from "../../state/store.js"; -import { editMeta, editStudyLog } from "../../db/writes.js"; +import { editStudyLog } from "../../db/writes.js"; +import { + GRAMMAR_LEARNED, + GRAMMAR_NOTES, + TRAINER_CONJUGATION, + readJsonMeta, + writeJsonMeta, +} from "../../domain/notes.js"; import { haeche, past, explain, irregularClass } from "@lib/conjugation.js"; import { Keyboard, useComposer } from "../keyboard/Keyboard.js"; import grammarJson from "@data/grammar.json"; @@ -107,7 +114,7 @@ function ConjugationTrainer() { const nextScore = { n: score.n + 1, ok: score.ok + (ok ? 1 : 0) }; setScore(nextScore); await editStudyLog(db, today, { drills: 1 }); - await editMeta(db, "trainer.conjugation", JSON.stringify(nextScore)); + await writeJsonMeta(db, TRAINER_CONJUGATION, nextScore); invalidate(); checking.current = false; }; @@ -230,19 +237,20 @@ export function GrammarTab() { const [cat, setCat] = useState(POINTS[0]?.cat ?? "all"); const [open, setOpen] = useState(null); const [learned, setLearned] = useState>({}); + const [notes, setNotes] = useState>({}); const cats = useMemo(() => [...new Set(POINTS.map((p) => p.cat))], []); useEffect(() => { let cancelled = false; (async () => { - const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = 'grammar.learned'"); - if (cancelled || !row) return; - try { - setLearned(JSON.parse(row.v) as Record); - } catch { - /* a corrupt value is not worth failing the tab over */ - } + const [flags, saved] = await Promise.all([ + readJsonMeta>(db, GRAMMAR_LEARNED, {}), + readJsonMeta>(db, GRAMMAR_NOTES, {}), + ]); + if (cancelled) return; + setLearned(flags); + setNotes(saved); })(); return () => { cancelled = true; @@ -252,7 +260,17 @@ export function GrammarTab() { const toggleLearned = async (id: string) => { const next = { ...learned, [id]: !learned[id] }; setLearned(next); - await editMeta(db, "grammar.learned", JSON.stringify(next)); + await writeJsonMeta(db, GRAMMAR_LEARNED, next); + }; + + /** Persisted on blur rather than per keystroke — one meta row rewritten + whole, and a stamped user edit each time. */ + const saveNote = async (id: string, text: string) => { + const next = { ...notes }; + if (text.trim()) next[id] = text; + else delete next[id]; + setNotes(next); + await writeJsonMeta(db, GRAMMAR_NOTES, next); }; const shown = POINTS.filter((p) => p.cat === cat); @@ -308,6 +326,12 @@ export function GrammarTab() { +