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() { +