514 lines
19 KiB
JavaScript
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,
|
|
};
|