const db = require('../db/db'); const { stripTags } = require('../utils/sanitize'); /* ── валидация URL картинки ──────────────────────────────────────────────── Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/) — защита от javascript:/data:/внешних URL в src. Всё прочее → пустая строка. */ function safeImg(url) { if (typeof url !== 'string') return ''; const u = url.trim(); return /^\/uploads\/flashcards\/[A-Za-z0-9._-]+$/.test(u) ? u : ''; } /* ── SM-2 (Anki-стиль: кнопки различаются) ───────────────────────────────── quality: 0/2 = Снова, 3 = Трудно, 4 = Знаю, 5 = Легко. В отличие от чистого SM-2, интервал зависит от оценки уже на первых повторах: на новой карте Снова/Трудно/Знаю → 1д, Легко → 4д; на зрелых — Трудно ×1.2, Знаю ×ef, Легко ×ef×1.3 (easy-бонус). ВАЖНО: клиентское превью fcNextInterval() в flashcards.html — точная копия этой логики интервалов. ─────────────────────────────────────────────────────────────────────── */ const FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3; 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 = (quality === 5) ? 4 : 1; // выпуск: Легко 4д, иначе 1д } else if (n === 1) { iv = (quality === 3) ? 3 : (quality === 4) ? 6 : Math.round(6 * FC_EASY_BONUS); } else { if (quality === 3) iv = Math.max(iv + 1, Math.round(iv * FC_HARD_MULT)); else if (quality === 4) iv = Math.round(iv * ef); else iv = Math.round(iv * ef * FC_EASY_BONUS); } 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 front_image = safeImg(req.body.front_image); const back_image = safeImg(req.body.back_image); 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, front_image, back_image, order_idx) VALUES (?,?,?,?,?,?)`) .run(deck.id, front, back, front_image, back_image, maxIdx + 1); res.json({ id: r.lastInsertRowid, deck_id: deck.id, front, back, front_image, back_image, 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, front_image, back_image, order_idx) VALUES (?,?,?,?,?,?)`); const inserted = []; const ins = db.transaction(() => { cards.forEach((c, i) => { const front = stripTags((c.front || '').slice(0, 5000)); const back = stripTags((c.back || '').slice(0, 5000)); const fImg = safeImg(c.front_image); const bImg = safeImg(c.back_image); const r = stmt.run(deck.id, front, back, fImg, bImg, maxIdx + 1 + i); inserted.push({ id: r.lastInsertRowid, front, back, front_image: fImg, back_image: bImg }); }); }); ins(); res.json({ inserted }); } /* ── PUT /api/flashcards/decks/:id/reorder ───────────────────────────────── body: { order: [cardId, …] } — переписывает order_idx по позиции в массиве. Принимаются только карточки, реально принадлежащие колоде владельца. */ function reorderCards(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 { order } = req.body; if (!Array.isArray(order) || !order.length) return res.status(400).json({ error: 'order[] required' }); const owned = new Set( db.prepare(`SELECT id FROM flashcard_cards WHERE deck_id = ?`).all(deck.id).map(r => r.id) ); const stmt = db.prepare(`UPDATE flashcard_cards SET order_idx = ? WHERE id = ? AND deck_id = ?`); const run = db.transaction(() => { let idx = 0; order.forEach(id => { if (owned.has(Number(id))) stmt.run(idx++, Number(id), deck.id); }); }); run(); res.json({ ok: true }); } /* ── 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; // Картинки трогаем только если поле реально пришло (пустая строка = очистить). const frontImg = ('front_image' in req.body) ? safeImg(req.body.front_image) : card.front_image; const backImg = ('back_image' in req.body) ? safeImg(req.body.back_image) : card.back_image; db.prepare(`UPDATE flashcard_cards SET front=?, back=?, front_image=?, back_image=? WHERE id=?`) .run(front ?? card.front, back ?? card.back, frontImg, backImg, card.id); res.json({ ok: true, front_image: frontImg, back_image: backImg }); } /* ── 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, c.front_image, c.back_image, 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 }); } /* ── POST /api/flashcards/quick — быстрое добавление из любой точки ────── Кладёт карточку в указанную колоду (deckId) либо в личную колоду «Быстрые карточки» (создаётся при первом обращении). */ const QUICK_DECK_TITLE = 'Быстрые карточки'; function quickAdd(req, res) { const uid = req.user.id; const front = stripTags((req.body.front || '').slice(0, 5000)).trim(); const back = stripTags((req.body.back || '').slice(0, 5000)).trim(); const front_image = safeImg(req.body.front_image); const back_image = safeImg(req.body.back_image); if (!front && !front_image) return res.status(400).json({ error: 'Заполни лицевую сторону (текст или картинку)' }); let deck = null; const deckId = Number(req.body.deckId) || 0; if (deckId) { deck = db.prepare(`SELECT id, title, color FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(deckId, uid); } if (!deck) { deck = db.prepare(`SELECT id, title, color FROM flashcard_decks WHERE user_id = ? AND title = ? ORDER BY id LIMIT 1`) .get(uid, QUICK_DECK_TITLE); if (!deck) { const r = db.prepare( `INSERT INTO flashcard_decks (user_id, title, description, color) VALUES (?,?,?,?)` ).run(uid, QUICK_DECK_TITLE, 'Карточки, добавленные на лету', '#9B5DE5'); deck = { id: r.lastInsertRowid, title: QUICK_DECK_TITLE, color: '#9B5DE5', created: true }; } } 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, front_image, back_image, order_idx) VALUES (?,?,?,?,?,?)`) .run(deck.id, front, back, front_image, back_image, maxIdx + 1); res.json({ id: r.lastInsertRowid, deck_id: deck.id, deck_title: deck.title, deck_color: deck.color, front, back, front_image, back_image }); } /* ── GET /api/flashcards/random — случайная карточка из всего пула ─────── Для дашборд-виджета «повтори карточку». */ function getRandom(req, res) { const uid = req.user.id; const total = 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; if (!total) return res.json({ card: null, total: 0 }); const card = db.prepare(` SELECT c.id, c.front, c.back, c.front_image, c.back_image, c.deck_id, d.title AS deck_title, d.color AS deck_color FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id WHERE d.user_id = ? ORDER BY RANDOM() LIMIT 1 `).get(uid); res.json({ card, total }); } /* ── POST /api/flashcards/upload — загрузка картинки для карточки ────────── multipart (поле file). Возвращает { url } для сохранения в front_image / back_image. Сам файл уже на диске (multer); БД здесь не трогаем. */ function uploadImage(req, res) { if (!req.file) return res.status(400).json({ error: 'Файл не получен (только изображения до 5 МБ)' }); res.json({ url: `/uploads/flashcards/${req.file.filename}` }); } module.exports = { listDecks, createDeck, updateDeck, deleteDeck, getCards, addCard, addCardsBulk, updateCard, deleteCard, reorderCards, getStudySession, submitReview, getStats, quickAdd, getRandom, uploadImage, };