ad7265d553
Раньше на новой карте Снова/Трудно/Знаю/Легко все давали 1 день (чистый SM-2: оценка влияла только на ease factor). Теперь интервал зависит от оценки: новая карта Легко=4д (остальные 1д), на повторах Трудно ×1.2 / Знаю ×ef / Легко ×ef×1.3 (easy-бонус). Серверный sm2() и клиентское превью fcNextInterval синхронны — проверено 0 расхождений на 256 комбинациях. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
367 lines
18 KiB
JavaScript
367 lines
18 KiB
JavaScript
const db = require('../db/db');
|
|
const { stripTags } = require('../utils/sanitize');
|
|
|
|
/* ── валидация URL картинки ────────────────────────────────────────────────
|
|
Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) —
|
|
защита от 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,
|
|
};
|