diff --git a/app/public/dict/band-0.json.gz b/app/public/dict/band-0.json.gz index c072f9a..93281af 100644 Binary files a/app/public/dict/band-0.json.gz and b/app/public/dict/band-0.json.gz differ diff --git a/app/public/dict/band-1.json.gz b/app/public/dict/band-1.json.gz index d53e157..41a89fc 100644 Binary files a/app/public/dict/band-1.json.gz and b/app/public/dict/band-1.json.gz differ diff --git a/app/public/dict/band-2.json.gz b/app/public/dict/band-2.json.gz index f6a4f81..332eeb0 100644 Binary files a/app/public/dict/band-2.json.gz and b/app/public/dict/band-2.json.gz differ diff --git a/app/public/dict/band-3.json.gz b/app/public/dict/band-3.json.gz index c7b7582..cd4d9a6 100644 Binary files a/app/public/dict/band-3.json.gz and b/app/public/dict/band-3.json.gz differ diff --git a/app/public/dict/band-4.json.gz b/app/public/dict/band-4.json.gz index 62ffff7..a1576e2 100644 Binary files a/app/public/dict/band-4.json.gz and b/app/public/dict/band-4.json.gz differ diff --git a/app/public/dict/band-5.json.gz b/app/public/dict/band-5.json.gz index c904f69..1cfb71c 100644 Binary files a/app/public/dict/band-5.json.gz and b/app/public/dict/band-5.json.gz differ diff --git a/app/public/dict/band-6.json.gz b/app/public/dict/band-6.json.gz index 5acf3a7..aaee92d 100644 Binary files a/app/public/dict/band-6.json.gz and b/app/public/dict/band-6.json.gz differ diff --git a/app/public/dict/manifest.json b/app/public/dict/manifest.json index e995947..b9ccd6e 100644 --- a/app/public/dict/manifest.json +++ b/app/public/dict/manifest.json @@ -1,4 +1,5 @@ { + "format": 2, "builtWith": { "dictionary": "kaikki", "dictionaryEntries": 33419, @@ -14,8 +15,8 @@ "file": "band-0.json.gz", "lemmas": 801, "surfaces": 1176, - "bytes": 24941, - "sha256": "4173cde20d7434c3e6a9a5062f2704884920ad59f3c156451408c40a4b5d8134", + "bytes": 21332, + "sha256": "e631e95e0263995c23ee58a5a3ed7cadf11d7b10566449192d644ccb2be7fe04", "reference": false }, { @@ -23,8 +24,8 @@ "file": "band-1.json.gz", "lemmas": 1240, "surfaces": 2335, - "bytes": 54275, - "sha256": "4161561294ecf7214b2f2dd674ebcd5aeb8ae085bbca90aa7cd52ee40afda2ce", + "bytes": 48215, + "sha256": "f4ab15154ed2cefd0aa86346d874c6f3b54c8455e6c58f1b20cd83750b8402e0", "reference": false }, { @@ -32,8 +33,8 @@ "file": "band-2.json.gz", "lemmas": 1450, "surfaces": 2590, - "bytes": 61269, - "sha256": "f3cc9662acbb90d4e1af918445c12ffef50c708ef0e6091a38e7e97b56e5bc10", + "bytes": 54698, + "sha256": "2a48e603909cd969423cc444cfac215e7a2e51c6129a80e0779cbeed0c81ebd1", "reference": false }, { @@ -41,8 +42,8 @@ "file": "band-3.json.gz", "lemmas": 1972, "surfaces": 3373, - "bytes": 81310, - "sha256": "3b981e160ecc97b54006424106bf9b8ca6495f0c2d24a801e1a53496ca0d867d", + "bytes": 72713, + "sha256": "1b5bf666475b301930a99b2c23a8d7a9e585a3cbd40a4787feeebdf96e6ad2af", "reference": false }, { @@ -50,8 +51,8 @@ "file": "band-4.json.gz", "lemmas": 2987, "surfaces": 4775, - "bytes": 119241, - "sha256": "37843887a427579c41eeb02ddd907e5ce066d75f422aee019c8e7a76d3452f66", + "bytes": 106990, + "sha256": "a1ec6eb219b3b1c303c26296d47e5341329e9773b7a30432729288edbda91957", "reference": false }, { @@ -59,8 +60,8 @@ "file": "band-5.json.gz", "lemmas": 6963, "surfaces": 10347, - "bytes": 270967, - "sha256": "baf78213827168cc9074724b2ab1e339d15843b77401645d90931e99e332b2d5", + "bytes": 245251, + "sha256": "1f8576b0d5686b67aece0a5b7a2bf291e5680f3fb7f43ec95a49cf7839d5730d", "reference": false }, { @@ -68,8 +69,8 @@ "file": "band-6.json.gz", "lemmas": 15107, "surfaces": 19283, - "bytes": 551959, - "sha256": "b3bf35f53f8f591609075609612442cc3b1ee898f3ad304416f8e49cc1718e7a", + "bytes": 499732, + "sha256": "f9e2ed69ddb4515192d6e24a8abe863656c993414f6e28948ef59d78c6d0056c", "reference": true } ], diff --git a/app/public/dict/seed.sqlite3 b/app/public/dict/seed.sqlite3 index ed1e362..649f414 100644 Binary files a/app/public/dict/seed.sqlite3 and b/app/public/dict/seed.sqlite3 differ diff --git a/app/src/db/migrate.ts b/app/src/db/migrate.ts index 9c1fc37..c166dd4 100644 --- a/app/src/db/migrate.ts +++ b/app/src/db/migrate.ts @@ -17,17 +17,26 @@ async function readVersion(db: Db): Promise { * Bring the database up to SCHEMA_VERSION. Idempotent: running it twice is a * no-op, which is what makes it safe to call on every launch. * + * Each migration's SQL and its data step run in ONE transaction with the + * version bump, so a migration either happened completely or not at all. + * * The version row is app bookkeeping, not a user edit, so it is written with * updated_at = 0 — see the timestamp rule in migrations.ts. + * + * `upTo` exists for tests that need a database as an older build left it. */ -export async function migrate(db: Db): Promise<{ from: number; to: number }> { +export async function migrate( + db: Db, + upTo: number = SCHEMA_VERSION, +): Promise<{ from: number; to: number }> { await db.exec(BOOTSTRAP_SQL); const from = await readVersion(db); for (const m of MIGRATIONS) { - if (m.id <= from) continue; + if (m.id <= from || m.id > upTo) continue; await db.tx(async (tx) => { await tx.exec(m.sql); + if (m.run) await m.run(tx); await tx.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES (?, ?, 0)", [ VERSION_KEY, String(m.id), @@ -35,5 +44,5 @@ export async function migrate(db: Db): Promise<{ from: number; to: number }> { }); } - return { from, to: SCHEMA_VERSION }; + return { from, to: Math.min(upTo, SCHEMA_VERSION) }; } diff --git a/app/src/db/migrations.ts b/app/src/db/migrations.ts index 4f2364c..8fc76f1 100644 --- a/app/src/db/migrations.ts +++ b/app/src/db/migrations.ts @@ -15,10 +15,21 @@ Sync itself is out of scope for this pass; `change_seq` is deliberately absent because it is server-assigned and arrives with the sync layer. */ +import type { Db } from "./types.js"; +import { lemmaId } from "@shared/lemma-id.mjs"; + export interface Migration { id: number; name: string; sql: string; + /** + * A data step, for what SQL cannot express. Runs after `sql`, inside the + * same transaction. It converts representation only: it must never stamp + * a row or invent user data — see "Migrations run after the first pull, + * never at boot" in PORT.md; a repair that empties something belongs in + * the sync layer's afterHydration hook, not here. + */ + run?: (db: Db) => Promise; } export const MIGRATIONS: Migration[] = [ @@ -149,8 +160,104 @@ export const MIGRATIONS: Migration[] = [ DELETE FROM meta WHERE k = 'seed.known'; `, }, + { + id: 6, + name: "stable lemma ids — a card names its word, not a build position", + sql: /* sql */ ` + -- The learner's own words, as data that can travel. A custom lemma row + -- is reference data derived from this; it used to BE the only record, + -- under an id that meant a different word on every device. + CREATE TABLE IF NOT EXISTS custom_word ( + headword TEXT NOT NULL, + pos TEXT NOT NULL, + gloss TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (headword, pos) + ) WITHOUT ROWID; + `, + run: rekeyLemmas, + }, ]; +/** + * Migration 6's data step. + * + * Ids were positions in the sorted build, so a dictionary change moved cards + * onto other words. They are now lemmaId(headword, pos). Every reference to + * an old id is rewritten through the lemma rows still loaded — the only place + * the old id's word is recorded — and then the dictionary itself is dropped, + * so the bands reload under the new ids. + * + * Nothing is stamped: the rows are the same rows, renamed. A card whose lemma + * is not loaded cannot be named, so it is left exactly as it was. + */ +async function rekeyLemmas(db: Db): Promise { + const named = await db.all<{ id: number; headword: string; pos: string }>( + "SELECT id, headword, pos FROM lemma", + ); + const next = new Map(named.map((l) => [l.id, lemmaId(l.headword, l.pos)])); + + /* Cards. Two passes through negative ids, so no new id can collide with + an old one that has not moved yet. */ + const cards = await db.all<{ lemma_id: number }>("SELECT lemma_id FROM card"); + for (const { lemma_id } of cards) { + const to = next.get(lemma_id); + if (to !== undefined && to !== lemma_id) { + await db.run("UPDATE card SET lemma_id = ? WHERE lemma_id = ?", [-to, lemma_id]); + } + } + await db.run("UPDATE card SET lemma_id = -lemma_id WHERE lemma_id < 0"); + + /* A card's tombstone names it by the same id, as text. */ + const graves = await db.all<{ pk: string }>("SELECT pk FROM tombstone WHERE tbl = 'card'"); + for (const { pk } of graves) { + const to = next.get(Number(pk)); + if (to !== undefined) { + await db.run( + "INSERT OR REPLACE INTO tombstone (tbl, pk, updated_at) SELECT 'card', ?, updated_at FROM tombstone WHERE tbl = 'card' AND pk = ?", + [String(to), pk], + ); + if (String(to) !== pk) await db.run("DELETE FROM tombstone WHERE tbl = 'card' AND pk = ?", [pk]); + } + } + + /* The learner's own words become custom_word rows, carrying the time their + card was made — the moment he added them — and their lemma is re-made + under its stable id. */ + const custom = await db.all<{ headword: string; pos: string; gloss_en: string }>( + "SELECT headword, pos, gloss_en FROM lemma WHERE source = 'custom'", + ); + // The cards have already moved, so each is found under its new id. + for (const w of custom) { + const id = lemmaId(w.headword, w.pos); + const card = await db.get<{ updated_at: number }>("SELECT updated_at FROM card WHERE lemma_id = ?", [id]); + await db.run( + "INSERT OR IGNORE INTO custom_word (headword, pos, gloss, updated_at) VALUES (?, ?, ?, ?)", + [w.headword, w.pos, w.gloss_en, card?.updated_at ?? 0], + ); + } + + /* Drop the dictionary. The custom lemmas go too and are re-made from + custom_word; the shipped bands reload on the next boot, under new ids, + because nothing is recorded as loaded any more. */ + await db.run("DELETE FROM surface"); + await db.run("DELETE FROM lemma"); + await db.run("DELETE FROM meta WHERE k IN ('dict.loadedBands', 'dict.version')"); + for (const w of custom) { + const id = lemmaId(w.headword, w.pos); + await db.run( + `INSERT OR REPLACE INTO lemma (id, headword, pos, freq_rank, level, gloss_en, gloss_ko, unit_band, source) + VALUES (?, ?, ?, NULL, NULL, ?, '', 0, 'custom')`, + [id, w.headword, w.pos, w.gloss_en], + ); + await db.run("INSERT OR REPLACE INTO surface (form, lemma_id, analysis) VALUES (?, ?, ?)", [ + w.headword, + id, + "headword, custom", + ]); + } +} + export const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]!.id; /** Bootstrap DDL: meta must exist before we can read the schema version. */ diff --git a/app/src/db/writes.ts b/app/src/db/writes.ts index 1af3770..9bee094 100644 --- a/app/src/db/writes.ts +++ b/app/src/db/writes.ts @@ -26,6 +26,7 @@ import type { Db, Params } from "./types.js"; import type { Card, Grade } from "@lib/srs.js"; +import { lemmaId } from "@shared/lemma-id.mjs"; /* ───────────────────────────────────────────────────────────────────── The clock. This is the ONLY place in src/db/ that reads it. @@ -215,17 +216,15 @@ 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. - ═════════════════════════════════════════════════════════════════════ */ + The record is a `custom_word` row: user data, stamped, and the thing that + travels between devices. The `lemma` and `surface` rows that let the rest + of the app treat it like any other word are derived from it. -/** First id available to custom words. The build never emits ids this high. */ -export const CUSTOM_LEMMA_BASE = 10_000_000; + A custom word's lemma id is lemmaId(headword, pos) — the same function the + dictionary build uses — so it cannot collide with a shipped word, a later + rebuild cannot overwrite it with something else, and the same word added + on a phone and a laptop is the same card on both. + ═════════════════════════════════════════════════════════════════════ */ export interface CustomWord { headword: string; @@ -271,15 +270,17 @@ export async function editAddCustomWord(db: Db, word: CustomWord): Promise( - "SELECT max(id) AS id FROM lemma WHERE id >= ?", - [CUSTOM_LEMMA_BASE], - ); - const id = Math.max(CUSTOM_LEMMA_BASE, (top?.id ?? 0) + 1); + const id = lemmaId(headword, word.pos); + const t = now(); await tx.run( - `INSERT INTO lemma (id, headword, pos, freq_rank, level, gloss_en, gloss_ko, - unit_band, source) + `INSERT INTO custom_word (headword, pos, gloss, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(headword, pos) DO UPDATE SET gloss = excluded.gloss, updated_at = excluded.updated_at`, + [headword, word.pos, gloss, t], + ); + await tx.run( + `INSERT OR REPLACE 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], ); @@ -290,20 +291,28 @@ export async function editAddCustomWord(db: Db, word: CustomWord): Promise { - if (lemmaId < CUSTOM_LEMMA_BASE) return; // never touch shipped dictionary rows await db.tx(async (tx) => { + const word = await tx.get<{ headword: string; pos: string }>( + "SELECT headword, pos FROM lemma WHERE id = ? AND source = 'custom'", + [lemmaId], + ); + if (!word) return; // never touch shipped dictionary rows + 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]); + await tx.run("DELETE FROM custom_word WHERE headword = ? AND pos = ?", [word.headword, word.pos]); await tombstone(tx, "card", lemmaId); + await tombstone(tx, "custom_word", JSON.stringify([word.headword, word.pos])); }); } @@ -338,11 +347,17 @@ export async function editReset(db: Db, scope: ResetScope): Promise { for (const r of await tx.all<{ form: string }>("SELECT form FROM peek")) await tombstone(tx, "peek", r.form); + for (const r of await tx.all<{ headword: string; pos: string }>( + "SELECT headword, pos FROM custom_word", + )) + await tombstone(tx, "custom_word", JSON.stringify([r.headword, r.pos])); + 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]); + await tx.run("DELETE FROM custom_word"); + await tx.run("DELETE FROM surface WHERE lemma_id IN (SELECT id FROM lemma WHERE source = 'custom')"); + await tx.run("DELETE FROM lemma WHERE source = 'custom'"); // Preferences, grammar flags and notes, and the trainer score. // Device bookkeeping (schema_version, dict.*) is left alone — it // describes this install, not the learner. diff --git a/app/src/domain/dictionary.ts b/app/src/domain/dictionary.ts index ce5c29e..c4caf62 100644 --- a/app/src/domain/dictionary.ts +++ b/app/src/domain/dictionary.ts @@ -18,6 +18,7 @@ import type { Db } from "../db/types.js"; import { insertBand, seedMeta, type LemmaRow, type SurfaceRow } from "../db/writes.js"; import { REFERENCE_BAND } from "@shared/bands.mjs"; +import { lemmaId } from "@shared/lemma-id.mjs"; const BASE = `${import.meta.env.BASE_URL ?? "/"}dict/`; @@ -32,6 +33,7 @@ export interface BandInfo { } export interface DictManifest { + format?: number; builtWith: { dictionary: string; dictionaryEntries: number; frequencyForms: number }; totals: { lemmas: number; surfaces: number }; bands: BandInfo[]; @@ -41,11 +43,22 @@ export interface DictManifest { interface BandPayload { band: number; + format?: number; columns: { lemma: string[]; surface: string[] }; lemmas: unknown[][]; surfaces: unknown[][]; } +/** A lemma as the file carries it: no id, which is derived from the word. */ +type FileLemma = Omit; + +/** A surface as the file carries it: its lemma is a row index in the file. */ +interface FileSurface { + form: string; + lemma: number; + analysis: string; +} + let manifestPromise: Promise | null = null; export function loadManifest(): Promise { @@ -95,7 +108,61 @@ function toRows(columns: string[], rows: unknown[][]): T[] { }); } +/** + * Band file rows, as database rows. + * + * The files carry no ids (format 2). A lemma's id is lemmaId(headword, pos) — + * the same function the build asserted collision-free, and the one the + * app uses for custom words — so the id means the same word on every device + * and across every rebuild. A surface points at its lemma by row index. + */ +function rowsOf(payload: BandPayload): { lemmas: LemmaRow[]; surfaces: SurfaceRow[] } { + if (payload.format !== 2) { + throw new Error(`band ${payload.band}: format ${payload.format ?? 1} is not supported — rebuild the dictionary`); + } + const lemmas = toRows(payload.columns.lemma, payload.lemmas).map((l) => ({ + ...l, + id: lemmaId(l.headword, l.pos), + })); + const surfaces = toRows(payload.columns.surface, payload.surfaces).map((s) => { + const lemma = lemmas[s.lemma]; + if (!lemma) throw new Error(`band ${payload.band}: surface ${s.form} names a lemma row that does not exist`); + return { form: s.form, lemma_id: lemma.id, analysis: s.analysis }; + }); + return { lemmas, surfaces }; +} + const LOADED_KEY = "dict.loadedBands"; +const VERSION_KEY = "dict.version"; + +/** Which build of the dictionary is loaded: every band file's hash. */ +const versionOf = (m: DictManifest): string => + m.bands.map((b) => `${b.band}:${b.sha256.slice(0, 16)}`).join(","); + +/** + * Drop the loaded dictionary when the shipped one has changed. + * + * Loaded bands used to be recorded by number alone, so a rebuilt dictionary + * never reached an existing install: band 0 was "already loaded". Reloading + * is safe now that an id names a word rather than a position — every card + * still points at the same word afterwards. The learner's own words are + * reference data too, but not the shipped kind, so they stay. + */ +async function ensureCurrent(db: Db): Promise { + const want = versionOf(await loadManifest()); + const have = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [VERSION_KEY]); + if (have?.v === want) return; + + await db.tx(async (tx) => { + await tx.run( + "DELETE FROM surface WHERE lemma_id IN (SELECT id FROM lemma WHERE source <> 'custom')", + ); + await tx.run("DELETE FROM lemma WHERE source <> 'custom'"); + await tx.run("DELETE FROM meta WHERE k = ?", [LOADED_KEY]); + // Bookkeeping, not a user edit — never stamped, never synced. + await tx.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES (?, ?, 0)", [VERSION_KEY, want]); + }); +} /** * Band loading is a read-modify-write on one meta row, and it runs from two @@ -138,18 +205,15 @@ async function rememberBand(db: Db, band: number): Promise { /** Load one band if it is not already in the database. */ export function loadBand(db: Db, band: number): Promise { return serialise(async () => { + await ensureCurrent(db); if ((await loadedBands(db)).has(band)) return false; const manifest = await loadManifest(); const info = manifest.bands.find((b) => b.band === band); if (!info) return false; - const payload = await fetchBand(info.file); - await insertBand( - db, - toRows(payload.columns.lemma, payload.lemmas), - toRows(payload.columns.surface, payload.surfaces), - ); + const { lemmas, surfaces } = rowsOf(await fetchBand(info.file)); + await insertBand(db, lemmas, surfaces); await rememberBand(db, band); return true; }); diff --git a/app/src/state/store.tsx b/app/src/state/store.tsx index 3e92404..107add5 100644 --- a/app/src/state/store.tsx +++ b/app/src/state/store.tsx @@ -30,7 +30,7 @@ import { writeServerConfig, type ServerConfig, } from "../domain/server-config.js"; -import { trySync, type SyncResult } from "../sync/client.js"; +import { SYNC_PAUSED, trySync, type SyncResult } from "../sync/client.js"; import type { ProgressState } from "@lib/gate.js"; import type { FocusMode } from "../domain/gate.js"; @@ -243,6 +243,10 @@ export function StoreProvider({ const running = useRef(false); const syncNow = useCallback(async () => { if (!store || !server || running.current) return; + if (SYNC_PAUSED) { + setSyncState({ at: Date.now(), result: null, error: SYNC_PAUSED, running: false }); + return; + } running.current = true; setSyncState((s) => ({ ...s, running: true })); const result = await trySync(store.db, server); diff --git a/app/src/sync/client.ts b/app/src/sync/client.ts index 053ffb8..8adb423 100644 --- a/app/src/sync/client.ts +++ b/app/src/sync/client.ts @@ -46,6 +46,18 @@ export interface SyncResult { cursor: number; } +/** + * Why sync is not running, or null when it is. + * + * Cards are keyed by stable lemma ids now, while the server still holds rows + * written under the old positional ids. Exchanging them would plant cards + * under ids that name no word, on both sides. Everything stays local — which + * this app is built to do — until the sync protocol that replaces this one + * ships with its own server schema. + */ +export const SYNC_PAUSED: string | null = + "Sync is paused while the storage format changes. Everything is kept on this device"; + const CURSOR_KEY = "sync.cursor"; const PUSHED_KEY = "sync.pushedAt"; @@ -248,7 +260,7 @@ export async function trySync( cfg: SyncConfig | null, signal?: AbortSignal, ): Promise { - if (!cfg?.baseUrl || !cfg.token) return null; + if (!cfg?.baseUrl || !cfg.token || SYNC_PAUSED) return null; try { return await syncOnce(db, cfg, signal); } catch (err) { diff --git a/app/src/ui/tabs/VocabTab.tsx b/app/src/ui/tabs/VocabTab.tsx index 820aa5c..8a2c830 100644 --- a/app/src/ui/tabs/VocabTab.tsx +++ b/app/src/ui/tabs/VocabTab.tsx @@ -7,7 +7,7 @@ import { useEffect, useMemo, useState } from "react"; import { useStore } from "../../state/store.js"; import { useReview } from "../review/useReview.js"; import { deck, forget, markAsKnown, type CardStatus, type DeckEntry } from "../../domain/cards.js"; -import { CUSTOM_LEMMA_BASE, editAddCustomWord, editRemoveCustomWord } from "../../db/writes.js"; +import { editAddCustomWord, editRemoveCustomWord } from "../../db/writes.js"; import { search, type Entry } from "../../domain/lexicon.js"; import { ensureReferenceBand } from "../../domain/dictionary.js"; import { statusOf } from "@lib/srs.js"; @@ -238,7 +238,7 @@ export function VocabTab() { Reset )} - {e.lemmaId >= CUSTOM_LEMMA_BASE && ( + {e.source === "custom" && (