LAPractice/db.js

514 lines
19 KiB
JavaScript

'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,
};