7c32501e18
Секция игнорировала флаг allow_html и всегда экранировала текст/опции/ пояснение, из-за чего <div class=task-figure><img>, <b> и пр. показывались как сырой текст. Теперь — как в test-run.html: allow_html ? raw : esc. Также добавлен q.allow_html в SELECT списка вопросов (его не было в ответе API). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
251 lines
12 KiB
JavaScript
251 lines
12 KiB
JavaScript
const db = require('../db/db');
|
|
const { stripTags } = require('../utils/sanitize');
|
|
|
|
/* helper: find or create topic by name within a subject */
|
|
function resolveTopicId(subjectId, topicId, topicName) {
|
|
if (topicId) return Number(topicId);
|
|
if (!topicName?.trim()) return null;
|
|
const name = topicName.trim();
|
|
const existing = db.prepare('SELECT id FROM topics WHERE subject_id = ? AND LOWER(name) = LOWER(?)').get(subjectId, name);
|
|
if (existing) return existing.id;
|
|
return db.prepare('INSERT INTO topics (subject_id, name) VALUES (?, ?)').run(subjectId, name).lastInsertRowid;
|
|
}
|
|
|
|
/* ── GET /api/questions?subject=bio&topic_id=1&sort=diff_asc&page=1&limit=50 */
|
|
function list(req, res) {
|
|
const { subject, topic_id, sort, source_type, q, difficulty, type } = req.query;
|
|
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
|
|
const page = Math.max(1, Number(req.query.page) || 1);
|
|
const offset = (page - 1) * limit;
|
|
|
|
const subj = subject
|
|
? db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject)
|
|
: null;
|
|
if (subject && !subj) return res.status(404).json({ error: 'Subject not found' });
|
|
|
|
const ORDER = {
|
|
date_desc: 'q.id DESC',
|
|
date_asc: 'q.id ASC',
|
|
diff_asc: 'q.difficulty ASC, q.id DESC',
|
|
diff_desc: 'q.difficulty DESC, q.id DESC',
|
|
};
|
|
const orderBy = ORDER[sort] || 'q.id DESC';
|
|
|
|
let where = 'WHERE 1=1';
|
|
const args = [];
|
|
if (subj) { where += ' AND q.subject_id = ?'; args.push(subj.id); }
|
|
if (topic_id) { where += ' AND q.topic_id = ?'; args.push(topic_id); }
|
|
if (source_type) { where += ' AND q.source_type = ?'; args.push(source_type); }
|
|
if (difficulty) { where += ' AND q.difficulty = ?'; args.push(Number(difficulty)); }
|
|
if (type) { where += ' AND q.type = ?'; args.push(type); }
|
|
if (q?.trim()) { where += ' AND q.text LIKE ?'; args.push(`%${q.trim()}%`); }
|
|
|
|
const { total } = db.prepare(`SELECT COUNT(*) AS total FROM questions q ${where}`).get(...args);
|
|
|
|
const sql = `
|
|
SELECT q.id, q.text, q.type, q.correct_text, q.difficulty, q.explanation, q.image,
|
|
q.year, q.source_type, q.allow_html,
|
|
t.name AS topic, t.id AS topic_id,
|
|
s.name AS subject_name, s.slug AS subject_slug,
|
|
(SELECT json_group_array(json_object(
|
|
'id', o.id, 'text', o.text, 'is_correct', o.is_correct, 'order_index', o.order_index, 'match_pair', o.match_pair
|
|
) ORDER BY o.order_index)
|
|
FROM options o WHERE o.question_id = q.id) AS options_json
|
|
FROM questions q
|
|
LEFT JOIN topics t ON t.id = q.topic_id
|
|
LEFT JOIN subjects s ON s.id = q.subject_id
|
|
${where}
|
|
ORDER BY ${orderBy} LIMIT ? OFFSET ?
|
|
`;
|
|
|
|
const rows = db.prepare(sql).all(...args, limit, offset).map(r => ({
|
|
...r,
|
|
options: JSON.parse(r.options_json || '[]'),
|
|
options_json: undefined,
|
|
}));
|
|
|
|
res.json({ rows, total, page, limit });
|
|
}
|
|
|
|
/* ── POST /api/questions ─────────────────────────────────────────────── */
|
|
function create(req, res) {
|
|
const { subject_slug, topic_id, topic_name, type = 'single', correct_text, difficulty = 1, explanation, options, image } = req.body;
|
|
const text = stripTags((req.body.text || '').trim());
|
|
|
|
if (!subject_slug || !text) return res.status(400).json({ error: 'subject_slug and text are required' });
|
|
if (text.length > 2000) return res.status(400).json({ error: 'text too long (max 2000 chars)' });
|
|
if (type !== 'short_answer' && !options?.length)
|
|
return res.status(400).json({ error: 'options required for this question type' });
|
|
if (type !== 'short_answer' && type !== 'matching' && !options.some(o => o.is_correct))
|
|
return res.status(400).json({ error: 'At least one option must be correct' });
|
|
|
|
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
|
|
if (!subj) return res.status(404).json({ error: 'Subject not found' });
|
|
|
|
const resolvedTopicId = resolveTopicId(subj.id, topic_id, topic_name);
|
|
|
|
try {
|
|
const qid = db.transaction(() => {
|
|
const { lastInsertRowid } = db.prepare(
|
|
'INSERT INTO questions (subject_id, topic_id, text, type, correct_text, difficulty, explanation, image) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
).run(subj.id, resolvedTopicId, text, type, type === 'short_answer' ? (correct_text || null) : null, difficulty, explanation || null, image || null);
|
|
|
|
const insertOpt = db.prepare(
|
|
'INSERT INTO options (question_id, text, is_correct, order_index, match_pair) VALUES (?, ?, ?, ?, ?)'
|
|
);
|
|
options.forEach((o, i) => insertOpt.run(lastInsertRowid, o.text, type === 'matching' ? 0 : (o.is_correct ? 1 : 0), i, o.match_pair || null));
|
|
return lastInsertRowid;
|
|
})();
|
|
res.status(201).json({ id: qid });
|
|
} catch (err) {
|
|
console.error('[question create]', err.message);
|
|
res.status(500).json({ error: 'Ошибка создания вопроса' });
|
|
}
|
|
}
|
|
|
|
/* ── POST /api/questions/:id/duplicate ──────────────────────────────── */
|
|
function duplicate(req, res) {
|
|
const q = db.prepare(`
|
|
SELECT q.*, s.slug AS subject_slug FROM questions q
|
|
LEFT JOIN subjects s ON s.id = q.subject_id
|
|
WHERE q.id = ?
|
|
`).get(req.params.id);
|
|
if (!q) return res.status(404).json({ error: 'Question not found' });
|
|
|
|
const opts = db.prepare('SELECT text, is_correct, order_index FROM options WHERE question_id = ? ORDER BY order_index').all(q.id);
|
|
|
|
try {
|
|
const newId = db.transaction(() => {
|
|
const { lastInsertRowid } = db.prepare(
|
|
'INSERT INTO questions (subject_id, topic_id, text, difficulty, explanation, type, correct_text, image) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
).run(q.subject_id, q.topic_id, q.text + ' (копия)', q.difficulty, q.explanation, q.type, q.correct_text, q.image);
|
|
|
|
const ins = db.prepare('INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?, ?, ?, ?)');
|
|
opts.forEach(o => ins.run(lastInsertRowid, o.text, o.is_correct, o.order_index));
|
|
return lastInsertRowid;
|
|
})();
|
|
res.status(201).json({ id: newId });
|
|
} catch (err) {
|
|
console.error('[question duplicate]', err.message);
|
|
res.status(500).json({ error: 'Ошибка дублирования вопроса' });
|
|
}
|
|
}
|
|
|
|
/* ── PUT /api/questions/:id ──────────────────────────────────────────── */
|
|
function update(req, res) {
|
|
const { type, correct_text, difficulty, explanation, topic_id, topic_name, options, image } = req.body;
|
|
const text = req.body.text !== undefined ? stripTags(String(req.body.text).trim()) : undefined;
|
|
const qid = req.params.id;
|
|
|
|
if (text !== undefined && text.length > 2000)
|
|
return res.status(400).json({ error: 'text too long (max 2000 chars)' });
|
|
|
|
const q = db.prepare('SELECT id, subject_id, type AS oldType FROM questions WHERE id = ?').get(qid);
|
|
if (!q) return res.status(404).json({ error: 'Question not found' });
|
|
|
|
const resolvedTopicId = resolveTopicId(q.subject_id, topic_id, topic_name);
|
|
const qtype = type || q.oldType || 'single';
|
|
|
|
try {
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
'UPDATE questions SET text = ?, type = ?, correct_text = ?, difficulty = ?, explanation = ?, topic_id = ?, image = ? WHERE id = ?'
|
|
).run(text, qtype, qtype === 'short_answer' ? (correct_text || null) : null, difficulty, explanation || null, resolvedTopicId, image !== undefined ? (image || null) : db.prepare('SELECT image FROM questions WHERE id = ?').get(qid)?.image, qid);
|
|
|
|
if (options?.length) {
|
|
if (qtype !== 'matching' && !options.some(o => o.is_correct))
|
|
throw Object.assign(new Error('At least one option must be correct'), { status: 400 });
|
|
|
|
db.prepare('DELETE FROM options WHERE question_id = ?').run(qid);
|
|
const ins = db.prepare(
|
|
'INSERT INTO options (question_id, text, is_correct, order_index, match_pair) VALUES (?, ?, ?, ?, ?)'
|
|
);
|
|
options.forEach((o, i) => ins.run(qid, o.text, qtype === 'matching' ? 0 : (o.is_correct ? 1 : 0), i, o.match_pair || null));
|
|
}
|
|
})();
|
|
res.json({ id: Number(qid) });
|
|
} catch (err) {
|
|
console.error('[question update]', err.message);
|
|
res.status(err.status || 500).json({ error: err.status ? err.message : 'Ошибка обновления' });
|
|
}
|
|
}
|
|
|
|
/* ── DELETE /api/questions/:id ───────────────────────────────────────── */
|
|
function remove(req, res) {
|
|
const q = db.prepare('SELECT id FROM questions WHERE id = ?').get(req.params.id);
|
|
if (!q) return res.status(404).json({ error: 'Question not found' });
|
|
db.prepare('DELETE FROM questions WHERE id = ?').run(req.params.id);
|
|
res.json({ deleted: Number(req.params.id) });
|
|
}
|
|
|
|
/* ── POST /api/questions/import (CSV upload) ────────────────────────── */
|
|
/* CSV format (semicolon-separated, first row = header):
|
|
subject_slug;topic;text;difficulty;type;opt1;c1;opt2;c2;opt3;c3;opt4;c4;correct_text;explanation;year
|
|
- type: single | multi | true_false | short_answer
|
|
- c1-c4: 1 = correct, 0 = wrong (ignored for short_answer)
|
|
- correct_text: used only for short_answer type
|
|
*/
|
|
function importCSV(req, res) {
|
|
if (!req.file) return res.status(400).json({ error: 'CSV file required' });
|
|
|
|
const raw = req.file.buffer.toString('utf-8').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
const lines = raw.split('\n').map(l => l.trim()).filter(l => l.length);
|
|
if (lines.length < 2) return res.status(400).json({ error: 'CSV is empty or has only header' });
|
|
|
|
// skip header row
|
|
const dataLines = lines.slice(1);
|
|
const errors = [];
|
|
let imported = 0;
|
|
|
|
const insQ = db.prepare('INSERT INTO questions (subject_id, topic_id, text, type, correct_text, difficulty, explanation, year) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
|
const insOpt = db.prepare('INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?, ?, ?, ?)');
|
|
|
|
try {
|
|
db.transaction(() => {
|
|
for (let i = 0; i < dataLines.length; i++) {
|
|
const cols = dataLines[i].split(';');
|
|
const [subject_slug, topic, text, diffStr, type = 'single',
|
|
opt1 = '', c1 = '0', opt2 = '', c2 = '0',
|
|
opt3 = '', c3 = '0', opt4 = '', c4 = '0',
|
|
correct_text = '', explanation = '', yearStr = ''] = cols.map(c => c.trim());
|
|
|
|
if (!subject_slug || !text) { errors.push(`Строка ${i + 2}: пропущен subject_slug или text`); continue; }
|
|
|
|
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
|
|
if (!subj) { errors.push(`Строка ${i + 2}: неизвестный subject_slug «${subject_slug}»`); continue; }
|
|
|
|
const difficulty = Math.min(3, Math.max(1, Number(diffStr) || 1));
|
|
const year = yearStr ? Number(yearStr) || null : null;
|
|
const qtype = ['single', 'multi', 'true_false', 'short_answer'].includes(type) ? type : 'single';
|
|
const topicId = resolveTopicId(subj.id, null, topic || null);
|
|
|
|
const isShort = qtype === 'short_answer';
|
|
const options = [
|
|
{ text: opt1, is_correct: c1 === '1' },
|
|
{ text: opt2, is_correct: c2 === '1' },
|
|
{ text: opt3, is_correct: c3 === '1' },
|
|
{ text: opt4, is_correct: c4 === '1' },
|
|
].filter(o => o.text.length > 0);
|
|
|
|
if (!isShort && options.length < 2) { errors.push(`Строка ${i + 2}: нужно минимум 2 варианта ответа`); continue; }
|
|
if (!isShort && !options.some(o => o.is_correct)) { errors.push(`Строка ${i + 2}: нет правильного варианта (укажи 1 в столбце c1–c4)`); continue; }
|
|
|
|
const { lastInsertRowid: qid } = insQ.run(
|
|
subj.id, topicId, text, qtype,
|
|
isShort ? (correct_text || null) : null,
|
|
difficulty, explanation || null, year
|
|
);
|
|
if (!isShort) options.forEach((o, idx) => insOpt.run(qid, o.text, o.is_correct ? 1 : 0, idx));
|
|
imported++;
|
|
}
|
|
})();
|
|
} catch (e) {
|
|
console.error('[question import]', e.message);
|
|
return res.status(500).json({ error: 'Ошибка импорта' });
|
|
}
|
|
|
|
res.json({ imported, errors });
|
|
}
|
|
|
|
module.exports = { list, create, duplicate, update, remove, importCSV };
|