const db = require('../db/db'); const { stripTags } = require('../utils/sanitize'); /* ── SM-2 algorithm ─────────────────────────────────────────────────────── quality: 0 = blackout, 1 = wrong, 2 = wrong but familiar, 3 = correct with difficulty, 4 = correct, 5 = perfect ─────────────────────────────────────────────────────────────────────── */ function sm2(easeFactor, intervalDays, repetitions, quality) { let ef = easeFactor; let n = repetitions; let iv = intervalDays; if (quality < 3) { n = 0; iv = 1; } else { if (n === 0) iv = 1; else if (n === 1) iv = 6; else iv = Math.round(iv * ef); n++; } ef = Math.max(1.3, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)); const due = new Date(Date.now() + iv * 86400000).toISOString(); return { easeFactor: ef, intervalDays: iv, repetitions: n, dueAt: due }; } /* ── GET /api/flashcards/decks ─────────────────────────────────────────── */ function listDecks(req, res) { const uid = req.user.id; const decks = db.prepare(` SELECT d.*, (SELECT COUNT(*) FROM flashcard_cards c WHERE c.deck_id = d.id) AS card_count, (SELECT COUNT(*) FROM flashcard_cards c LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE c.deck_id = d.id AND (r.id IS NULL OR r.due_at <= datetime('now'))) AS due_count FROM flashcard_decks d WHERE d.user_id = ? ORDER BY d.created_at DESC `).all(uid, uid); res.json({ decks }); } /* ── POST /api/flashcards/decks ────────────────────────────────────────── */ function createDeck(req, res) { const uid = req.user.id; const { title, description = '', color = '#9B5DE5' } = req.body; if (!title?.trim()) return res.status(400).json({ error: 'Title required' }); const r = db.prepare( `INSERT INTO flashcard_decks (user_id, title, description, color) VALUES (?,?,?,?)` ).run(uid, title.trim(), description, color); res.json({ id: r.lastInsertRowid, title: title.trim(), description, color, card_count: 0, due_count: 0 }); } /* ── PUT /api/flashcards/decks/:id ─────────────────────────────────────── */ function updateDeck(req, res) { const uid = req.user.id; const { title, description, color } = req.body; const deck = db.prepare(`SELECT * FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); db.prepare(`UPDATE flashcard_decks SET title=?, description=?, color=? WHERE id=?`) .run(title ?? deck.title, description ?? deck.description, color ?? deck.color, deck.id); res.json({ ok: true }); } /* ── DELETE /api/flashcards/decks/:id ──────────────────────────────────── */ function deleteDeck(req, res) { const uid = req.user.id; const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); db.prepare(`DELETE FROM flashcard_decks WHERE id = ?`).run(deck.id); res.json({ ok: true }); } /* ── GET /api/flashcards/decks/:id/cards ───────────────────────────────── */ function getCards(req, res) { const uid = req.user.id; const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); const cards = db.prepare(` SELECT c.*, r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed FROM flashcard_cards c LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE c.deck_id = ? ORDER BY c.order_idx, c.id `).all(uid, deck.id); res.json({ cards }); } /* ── POST /api/flashcards/decks/:id/cards ──────────────────────────────── */ function addCard(req, res) { const uid = req.user.id; const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); const front = stripTags((req.body.front || '').slice(0, 5000)); const back = stripTags((req.body.back || '').slice(0, 5000)); const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`) .get(deck.id)?.m ?? -1; const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`) .run(deck.id, front, back, maxIdx + 1); res.json({ id: r.lastInsertRowid, deck_id: deck.id, front, back, order_idx: maxIdx + 1 }); } /* ── POST /api/flashcards/decks/:id/cards/bulk ──────────────────────────── */ function addCardsBulk(req, res) { const uid = req.user.id; const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); const { cards } = req.body; if (!Array.isArray(cards) || !cards.length) return res.status(400).json({ error: 'cards[] required' }); const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`) .get(deck.id)?.m ?? -1; const stmt = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`); const inserted = []; const ins = db.transaction(() => { cards.forEach((c, i) => { const r = stmt.run(deck.id, c.front || '', c.back || '', maxIdx + 1 + i); inserted.push({ id: r.lastInsertRowid, front: c.front, back: c.back }); }); }); ins(); res.json({ inserted }); } /* ── PUT /api/flashcards/cards/:id ─────────────────────────────────────── */ function updateCard(req, res) { const uid = req.user.id; const card = db.prepare(` SELECT c.* FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id WHERE c.id = ? AND d.user_id = ? `).get(req.params.id, uid); if (!card) return res.status(404).json({ error: 'Not found' }); const { front, back } = req.body; db.prepare(`UPDATE flashcard_cards SET front=?, back=? WHERE id=?`) .run(front ?? card.front, back ?? card.back, card.id); res.json({ ok: true }); } /* ── DELETE /api/flashcards/cards/:id ──────────────────────────────────── */ function deleteCard(req, res) { const uid = req.user.id; const card = db.prepare(` SELECT c.id FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id WHERE c.id = ? AND d.user_id = ? `).get(req.params.id, uid); if (!card) return res.status(404).json({ error: 'Not found' }); db.prepare(`DELETE FROM flashcard_cards WHERE id = ?`).run(card.id); res.json({ ok: true }); } /* ── GET /api/flashcards/decks/:id/study ───────────────────────────────── */ function getStudySession(req, res) { const uid = req.user.id; const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); // due cards first, then new cards (no review yet), limit 20 const cards = db.prepare(` SELECT c.id, c.front, c.back, COALESCE(r.ease_factor, 2.5) AS ease_factor, COALESCE(r.interval_days, 1) AS interval_days, COALESCE(r.repetitions, 0) AS repetitions, COALESCE(r.due_at, datetime('now')) AS due_at, r.last_reviewed, CASE WHEN r.id IS NULL THEN 0 ELSE 1 END AS seen FROM flashcard_cards c LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE c.deck_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now')) ORDER BY seen ASC, r.due_at ASC LIMIT 20 `).all(uid, deck.id); const total_due = db.prepare(` SELECT COUNT(*) AS n FROM flashcard_cards c LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE c.deck_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now')) `).get(uid, deck.id).n; res.json({ cards, total_due }); } /* ── POST /api/flashcards/cards/:id/review ─────────────────────────────── */ function submitReview(req, res) { const uid = req.user.id; const { quality } = req.body; // 0..5 if (quality === undefined || quality < 0 || quality > 5) return res.status(400).json({ error: 'quality 0-5 required' }); const card = db.prepare(` SELECT c.id FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id WHERE c.id = ? AND d.user_id = ? `).get(req.params.id, uid); if (!card) return res.status(404).json({ error: 'Not found' }); const existing = db.prepare( `SELECT * FROM flashcard_reviews WHERE user_id = ? AND card_id = ?` ).get(uid, card.id); const prev = existing || { ease_factor: 2.5, interval_days: 1, repetitions: 0 }; const next = sm2(prev.ease_factor, prev.interval_days, prev.repetitions, quality); if (existing) { db.prepare(` UPDATE flashcard_reviews SET ease_factor=?, interval_days=?, repetitions=?, due_at=?, last_reviewed=datetime('now') WHERE user_id=? AND card_id=? `).run(next.easeFactor, next.intervalDays, next.repetitions, next.dueAt, uid, card.id); } else { db.prepare(` INSERT INTO flashcard_reviews (user_id, card_id, ease_factor, interval_days, repetitions, due_at, last_reviewed) VALUES (?,?,?,?,?,?,datetime('now')) `).run(uid, card.id, next.easeFactor, next.intervalDays, next.repetitions, next.dueAt); } res.json({ ok: true, next_review: next.dueAt, interval_days: next.intervalDays }); } /* ── GET /api/flashcards/stats ─────────────────────────────────────────── */ function getStats(req, res) { const uid = req.user.id; const decks_count = db.prepare(`SELECT COUNT(*) AS n FROM flashcard_decks WHERE user_id=?`).get(uid).n; const cards_count = db.prepare(` SELECT COUNT(*) AS n FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id WHERE d.user_id=? `).get(uid).n; const due_count = db.prepare(` SELECT COUNT(*) AS n FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE d.user_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now')) `).get(uid, uid).n; const reviewed_today = db.prepare(` SELECT COUNT(*) AS n FROM flashcard_reviews WHERE user_id = ? AND date(last_reviewed) = date('now') `).get(uid).n; res.json({ decks_count, cards_count, due_count, reviewed_today }); } module.exports = { listDecks, createDeck, updateDeck, deleteDeck, getCards, addCard, addCardsBulk, updateCard, deleteCard, getStudySession, submitReview, getStats, };