'use strict'; const { DatabaseSync } = require('node:sqlite'); const path = require('node:path'); const fs = require('node:fs'); const DATA_DIR = path.join(__dirname, 'data'); fs.mkdirSync(DATA_DIR, { recursive: true }); const db = new DatabaseSync(process.env.LAP_DB || path.join(DATA_DIR, 'lapractice.db')); db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA foreign_keys = ON'); db.exec(` CREATE TABLE IF NOT EXISTS sets ( id INTEGER PRIMARY KEY, title TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY, set_id INTEGER NOT NULL REFERENCES sets(id) ON DELETE CASCADE, ext_id INTEGER, title TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, UNIQUE (set_id, title) ); CREATE TABLE IF NOT EXISTS subcategories ( id INTEGER PRIMARY KEY, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, name TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, UNIQUE (category_id, name) ); CREATE TABLE IF NOT EXISTS questions ( id INTEGER PRIMARY KEY, subcategory_id INTEGER NOT NULL REFERENCES subcategories(id) ON DELETE CASCADE, q TEXT NOT NULL, a TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE (subcategory_id, q) ); CREATE TABLE IF NOT EXISTS reviews ( id INTEGER PRIMARY KEY, question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE, correct INTEGER NOT NULL CHECK (correct IN (0, 1)), created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_reviews_question ON reviews(question_id); CREATE INDEX IF NOT EXISTS idx_reviews_created ON reviews(created_at); `); const now = () => new Date().toISOString(); function tx(fn) { db.exec('BEGIN'); try { const result = fn(); db.exec('COMMIT'); return result; } catch (err) { db.exec('ROLLBACK'); throw err; } } /* ---------------------------------------------------------------- import -- */ /** * Validates the shape of an imported question set and returns a normalised * structure. Throws an Error with a human readable message on bad input. */ function normalizeSet(raw, fallbackTitle) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { throw new Error('Die Datei enthaelt kein JSON-Objekt.'); } const title = String(raw.title || fallbackTitle || '').trim(); if (!title) throw new Error('Dem Fragenset fehlt ein "title".'); if (!Array.isArray(raw.categories) || raw.categories.length === 0) { throw new Error('Dem Fragenset fehlt ein nicht-leeres "categories"-Array.'); } const categories = raw.categories.map((cat, ci) => { const catTitle = String(cat?.title ?? cat?.name ?? '').trim(); if (!catTitle) throw new Error(`Kategorie #${ci + 1} hat keinen "title".`); // A category may either carry subcategories or questions directly; a flat // category is modelled as a single subcategory with the same name. const subsRaw = Array.isArray(cat.subcategories) ? cat.subcategories : [{ name: catTitle, questions: cat.questions }]; const subcategories = subsRaw.map((sub, si) => { const subName = String(sub?.name ?? sub?.title ?? '').trim() || catTitle; const qsRaw = Array.isArray(sub?.questions) ? sub.questions : []; const questions = []; const seen = new Set(); for (const item of qsRaw) { const q = String(item?.q ?? item?.question ?? '').trim(); const a = String(item?.a ?? item?.answer ?? '').trim(); if (!q || !a) continue; if (seen.has(q)) continue; // duplicates within one subcategory seen.add(q); questions.push({ q, a }); } return { name: subName, position: si, questions }; }).filter((sub) => sub.questions.length > 0); return { extId: Number.isFinite(cat.id) ? cat.id : ci + 1, title: catTitle, position: ci, subcategories, }; }).filter((cat) => cat.subcategories.length > 0); const total = categories.reduce( (sum, c) => sum + c.subcategories.reduce((s, sc) => s + sc.questions.length, 0), 0); if (total === 0) throw new Error('Im Fragenset wurde keine einzige Frage gefunden.'); return { title, categories, total }; } /** * Imports (or re-imports) a set. Re-importing a set with the same title merges: * questions are matched by category/subcategory/question text so that existing * answer statistics survive. Questions missing from the new file are archived * instead of deleted. */ function importSet(raw, fallbackTitle) { const set = normalizeSet(raw, fallbackTitle); const ts = now(); return tx(() => { let row = db.prepare('SELECT id FROM sets WHERE title = ?').get(set.title); let setId; let isNew = false; if (row) { setId = row.id; db.prepare('UPDATE sets SET updated_at = ? WHERE id = ?').run(ts, setId); } else { isNew = true; setId = db.prepare('INSERT INTO sets (title, created_at, updated_at) VALUES (?, ?, ?)') .run(set.title, ts, ts).lastInsertRowid; } const stats = { added: 0, updated: 0, unchanged: 0, archived: 0 }; const keptIds = new Set(); for (const cat of set.categories) { let catRow = db.prepare('SELECT id FROM categories WHERE set_id = ? AND title = ?') .get(setId, cat.title); let catId; if (catRow) { catId = catRow.id; db.prepare('UPDATE categories SET ext_id = ?, position = ? WHERE id = ?') .run(cat.extId, cat.position, catId); } else { catId = db.prepare( 'INSERT INTO categories (set_id, ext_id, title, position) VALUES (?, ?, ?, ?)') .run(setId, cat.extId, cat.title, cat.position).lastInsertRowid; } for (const sub of cat.subcategories) { let subRow = db.prepare('SELECT id FROM subcategories WHERE category_id = ? AND name = ?') .get(catId, sub.name); let subId; if (subRow) { subId = subRow.id; db.prepare('UPDATE subcategories SET position = ? WHERE id = ?').run(sub.position, subId); } else { subId = db.prepare( 'INSERT INTO subcategories (category_id, name, position) VALUES (?, ?, ?)') .run(catId, sub.name, sub.position).lastInsertRowid; } sub.questions.forEach((item, qi) => { const existing = db.prepare( 'SELECT id, a FROM questions WHERE subcategory_id = ? AND q = ?').get(subId, item.q); if (existing) { keptIds.add(existing.id); if (existing.a !== item.a) stats.updated++; else stats.unchanged++; db.prepare('UPDATE questions SET a = ?, position = ?, archived = 0 WHERE id = ?') .run(item.a, qi, existing.id); } else { const id = db.prepare( 'INSERT INTO questions (subcategory_id, q, a, position, created_at) VALUES (?, ?, ?, ?, ?)') .run(subId, item.q, item.a, qi, ts).lastInsertRowid; keptIds.add(id); stats.added++; } }); } } if (!isNew) { const all = db.prepare(` SELECT q.id FROM questions q JOIN subcategories s ON s.id = q.subcategory_id JOIN categories c ON c.id = s.category_id WHERE c.set_id = ?`).all(setId); const stale = all.map((r) => r.id).filter((id) => !keptIds.has(id)); const archive = db.prepare('UPDATE questions SET archived = 1 WHERE id = ?'); for (const id of stale) { archive.run(id); stats.archived++; } } return { setId, title: set.title, isNew, total: set.total, ...stats }; }); } /* ----------------------------------------------------------------- reads -- */ const QUESTION_STATS = ` (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id) AS reviews, (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id AND r.correct = 1) AS correct, (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id AND r.correct = 0) AS wrong, (SELECT r.correct FROM reviews r WHERE r.question_id = q.id ORDER BY r.id DESC LIMIT 1) AS last_correct, (SELECT r.created_at FROM reviews r WHERE r.question_id = q.id ORDER BY r.id DESC LIMIT 1) AS last_reviewed `; function listSets() { return db.prepare(` SELECT s.id, s.title, s.created_at, s.updated_at, (SELECT COUNT(*) FROM questions q JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id WHERE c.set_id = s.id AND q.archived = 0) AS questions, (SELECT COUNT(DISTINCT q.id) FROM questions q JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id JOIN reviews r ON r.question_id = q.id WHERE c.set_id = s.id AND q.archived = 0) AS seen, (SELECT COUNT(*) FROM reviews r JOIN questions q ON q.id = r.question_id JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id WHERE c.set_id = s.id) AS answers, (SELECT COUNT(*) FROM reviews r JOIN questions q ON q.id = r.question_id JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id WHERE c.set_id = s.id AND r.correct = 1) AS answers_correct FROM sets s ORDER BY s.updated_at DESC`).all().map((r) => ({ ...r })); } function getSet(setId) { const row = db.prepare('SELECT id, title, created_at, updated_at FROM sets WHERE id = ?').get(setId); return row ? { ...row } : null; } /** Full category tree of a set including per-subcategory progress counters. */ function getTree(setId) { const rows = db.prepare(` SELECT c.id AS category_id, c.title AS category, c.position AS cpos, sc.id AS subcategory_id, sc.name AS subcategory, sc.position AS spos, COUNT(q.id) AS total, SUM(CASE WHEN (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id) > 0 THEN 1 ELSE 0 END) AS seen, SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id ORDER BY r.id DESC LIMIT 1) = 1 THEN 1 ELSE 0 END) AS last_correct, SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id ORDER BY r.id DESC LIMIT 1) = 0 THEN 1 ELSE 0 END) AS last_wrong FROM categories c JOIN subcategories sc ON sc.category_id = c.id JOIN questions q ON q.subcategory_id = sc.id AND q.archived = 0 WHERE c.set_id = ? GROUP BY sc.id ORDER BY c.position, c.id, sc.position, sc.id`).all(setId); const byCategory = new Map(); for (const r of rows) { if (!byCategory.has(r.category_id)) { byCategory.set(r.category_id, { id: r.category_id, title: r.category, total: 0, seen: 0, lastCorrect: 0, lastWrong: 0, subcategories: [], }); } const cat = byCategory.get(r.category_id); cat.subcategories.push({ id: r.subcategory_id, name: r.subcategory, total: r.total, seen: r.seen, lastCorrect: r.last_correct, lastWrong: r.last_wrong, }); cat.total += r.total; cat.seen += r.seen; cat.lastCorrect += r.last_correct; cat.lastWrong += r.last_wrong; } return [...byCategory.values()]; } const FILTERS = new Set(['all', 'wrong', 'unseen']); /** * Builds a practice session: takes the questions of the chosen subcategories, * applies the filter, orders them (shuffled and/or weakest-first) and caps them * at `limit`. Order and filter are independent, so every combination works. */ function buildSession({ setId, subcategoryIds = [], filter = 'all', shuffle: doShuffle = true, smart = true, limit = 0, }) { if (!FILTERS.has(filter)) filter = 'all'; const params = [setId]; let scopeSql = ''; if (subcategoryIds.length) { scopeSql = ` AND sc.id IN (${subcategoryIds.map(() => '?').join(',')})`; params.push(...subcategoryIds); } const rows = db.prepare(` SELECT q.id, q.q, q.a, q.position, sc.id AS subcategory_id, sc.name AS subcategory, c.id AS category_id, c.title AS category, c.position AS cpos, sc.position AS spos, ${QUESTION_STATS} FROM questions q JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id WHERE c.set_id = ? AND q.archived = 0${scopeSql} ORDER BY c.position, c.id, sc.position, sc.id, q.position, q.id`).all(...params); let pool = rows.map((r) => ({ id: r.id, q: r.q, a: r.a, category: r.category, subcategory: r.subcategory, stats: { reviews: r.reviews, correct: r.correct, wrong: r.wrong, lastCorrect: r.last_correct === null ? null : !!r.last_correct, lastReviewed: r.last_reviewed, }, })); if (filter === 'wrong') pool = pool.filter((x) => x.stats.lastCorrect === false); if (filter === 'unseen') pool = pool.filter((x) => x.stats.reviews === 0); if (smart) pool = smartOrder(pool, doShuffle); else if (doShuffle) shuffle(pool); // sonst: Reihenfolge des Skriptums, so wie sortiert geladen if (limit > 0) pool = pool.slice(0, limit); return pool; } /** * Mixes repetition and new material: every third card is the weakest question * that is due again, the rest is fresh material. Cards that are already sitting * well (last answer correct, good accuracy, recently seen) go to the back. */ function smartOrder(pool, doShuffle = true) { const unseen = pool.filter((x) => x.stats.reviews === 0); const fresh = doShuffle ? shuffle(unseen) : unseen; const seen = pool.filter((x) => x.stats.reviews > 0); const due = seen.filter(needsRepeat).sort((a, b) => weight(b) - weight(a)); const solid = seen.filter((x) => !needsRepeat(x)).sort((a, b) => weight(b) - weight(a)); const out = []; while (fresh.length || due.length) { const takeDue = due.length && (out.length % 3 === 2 || !fresh.length); out.push(takeDue ? due.shift() : fresh.shift()); } return out.concat(solid); } function needsRepeat(item) { const s = item.stats; if (s.lastCorrect === false) return true; if (s.correct / s.reviews < 0.7) return true; const ageDays = s.lastReviewed ? (Date.now() - Date.parse(s.lastReviewed)) / 86400000 : 0; return ageDays >= 7; } function weight(item) { const s = item.stats; if (s.reviews === 0) return 1000; const accuracy = s.correct / s.reviews; let w = (1 - accuracy) * 500; if (s.lastCorrect === false) w += 300; const ageDays = s.lastReviewed ? (Date.now() - Date.parse(s.lastReviewed)) / 86400000 : 0; return w + Math.min(ageDays, 60); } function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } function recordReview(questionId, correct) { const exists = db.prepare('SELECT id FROM questions WHERE id = ?').get(questionId); if (!exists) throw new Error('Unbekannte Frage.'); db.prepare('INSERT INTO reviews (question_id, correct, created_at) VALUES (?, ?, ?)') .run(questionId, correct ? 1 : 0, now()); const row = db.prepare(`SELECT ${QUESTION_STATS} FROM questions q WHERE q.id = ?`).get(questionId); return { reviews: row.reviews, correct: row.correct, wrong: row.wrong, lastCorrect: !!row.last_correct, lastReviewed: row.last_reviewed, }; } function undoReview(reviewId) { const row = db.prepare('SELECT id FROM reviews WHERE id = ?').get(reviewId); if (!row) return false; db.prepare('DELETE FROM reviews WHERE id = ?').run(reviewId); return true; } /** Overall stats, per-category accuracy, weakest questions and a 14 day chart. */ function getStats(setId) { const totals = db.prepare(` SELECT (SELECT COUNT(*) FROM questions q JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id WHERE c.set_id = ? AND q.archived = 0) AS questions, (SELECT COUNT(DISTINCT r.question_id) FROM reviews r JOIN questions q ON q.id = r.question_id JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id WHERE c.set_id = ? AND q.archived = 0) AS seen, (SELECT COUNT(*) FROM reviews r JOIN questions q ON q.id = r.question_id JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id WHERE c.set_id = ?) AS answers, (SELECT COUNT(*) FROM reviews r JOIN questions q ON q.id = r.question_id JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id WHERE c.set_id = ? AND r.correct = 1) AS answers_correct`) .get(setId, setId, setId, setId); const categories = db.prepare(` SELECT c.id, c.title, COUNT(q.id) AS total, SUM(CASE WHEN (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id) > 0 THEN 1 ELSE 0 END) AS seen, SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id ORDER BY r.id DESC LIMIT 1) = 1 THEN 1 ELSE 0 END) AS last_correct, SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id ORDER BY r.id DESC LIMIT 1) = 0 THEN 1 ELSE 0 END) AS last_wrong FROM categories c JOIN subcategories sc ON sc.category_id = c.id JOIN questions q ON q.subcategory_id = sc.id AND q.archived = 0 WHERE c.set_id = ? GROUP BY c.id ORDER BY c.position, c.id`).all(setId).map((r) => ({ ...r })); const weakest = db.prepare(` SELECT q.id, q.q, c.title AS category, sc.name AS subcategory, ${QUESTION_STATS} FROM questions q JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id WHERE c.set_id = ? AND q.archived = 0 AND (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id AND r.correct = 0) > 0 ORDER BY wrong DESC, correct ASC, last_reviewed DESC LIMIT 25`).all(setId).map((r) => ({ ...r, last_correct: r.last_correct })); const daily = db.prepare(` SELECT substr(r.created_at, 1, 10) AS day, COUNT(*) AS answers, SUM(r.correct) AS correct FROM reviews r JOIN questions q ON q.id = r.question_id JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id WHERE c.set_id = ? GROUP BY day ORDER BY day DESC LIMIT 14`).all(setId).map((r) => ({ ...r })).reverse(); return { totals: { ...totals }, categories, weakest, daily }; } function resetStats(setId) { return tx(() => db.prepare(` DELETE FROM reviews WHERE question_id IN ( SELECT q.id FROM questions q JOIN subcategories sc ON sc.id = q.subcategory_id JOIN categories c ON c.id = sc.category_id WHERE c.set_id = ?)`).run(setId).changes); } function deleteSet(setId) { return tx(() => db.prepare('DELETE FROM sets WHERE id = ?').run(setId).changes); } module.exports = { db, importSet, normalizeSet, listSets, getSet, getTree, buildSession, recordReview, undoReview, getStats, resetStats, deleteSet, };