31a51956b6
Импорт 40 нечётных вариантов (v01, v03, ..., v79) в банк вопросов: - 400 questions с allow_html=1, source_type='экзамен 9', year=2025 - 540 options (single-choice) + correct_text (short_answer) - 40 tests (по 1 на вариант), title="Экзамен 9 — Вариант N" - exam9_variant_tests маппинг для назначения Назначение варианта как ДЗ на /exam9 (для учителей/админов): - Кнопка «Назначить как ДЗ» под заголовком варианта (только если test_id есть) - Модалка выбора классов + опциональный deadline - POST /api/assignments/bulk с test_id из exam9_variant_tests Поддержка HTML/SVG в вопросах банка через флаг questions.allow_html: - Миграция 003: ALTER TABLE questions ADD COLUMN allow_html - sessionController: SELECT возвращают allow_html и image - test-run.html: рендер q.text и opt.text как HTML при allow_html=1 - test-result.html: то же для explanation и opt.text - KaTeX: добавлены $...$ и $$...$$ delimiters в обеих страницах Бонус-фикс: bulkSchema требовал class_id (single), контроллер ждёт class_ids (array). Существующий вызов из classes.html был сломан; исправлено вместе. Команда: node backend/scripts/import-exam9.js (--all для всех 80)
187 lines
7.3 KiB
JavaScript
187 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* import-exam9.js — imports ODD variants from /frontend/js/exam9/variants/ into question bank.
|
||
*
|
||
* Usage:
|
||
* node backend/scripts/import-exam9.js # imports v01, v03, ..., v79
|
||
* node backend/scripts/import-exam9.js --all # imports ALL 80 variants
|
||
*
|
||
* Idempotent: drops existing exam9 tests/questions before re-import (uses exam9_variant_tests map).
|
||
*
|
||
* For each variant V it creates:
|
||
* - 1 row in tests (title="Экзамен 9 — Вариант V", subject_slug='math', show_answers=1)
|
||
* - 1 row in exam9_variant_tests (V → test.id)
|
||
* - 10 rows in questions (one per task; allow_html=1, explanation=sol)
|
||
* - For tasks with opts: rows in options (5 per task, 1 marked correct)
|
||
* - For tasks without opts: questions.type='short_answer', correct_text=parsed from sol
|
||
* - 10 rows in test_questions (linking)
|
||
*/
|
||
'use strict';
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const db = require('../src/db/db');
|
||
|
||
const SUBJECT_ID = 3; // math (per import-content.js mapping)
|
||
const VARIANTS_DIR = path.join(__dirname, '../../frontend/js/exam9/variants');
|
||
|
||
/* ── Parse answer from sol — supports both <div class="sol-ans">…</div> patterns ─ */
|
||
function parseAnswer(sol) {
|
||
if (!sol) return null;
|
||
const m = sol.match(/<div class="sol-ans">([\s\S]*?)<\/div>/);
|
||
if (!m) return null;
|
||
// Remove "Ответ:" prefix and HTML entities/spaces
|
||
let raw = m[1].replace(/<[^>]+>/g, '').replace(/ | /g, ' ').trim();
|
||
raw = raw.replace(/^Ответ[:\s]*/i, '').trim();
|
||
return raw;
|
||
}
|
||
|
||
/* ── For OPTS tasks: parse the answer letter (а/б/в/г/д) ─ */
|
||
function parseAnswerLetter(sol) {
|
||
const ans = parseAnswer(sol);
|
||
if (!ans) return null;
|
||
const m = ans.match(/^([а-дa-e])\s*[\)\.]/i);
|
||
return m ? m[1].toLowerCase() : null;
|
||
}
|
||
|
||
/* ── Find or create an admin user to own the imported tests ─ */
|
||
function findCreatedBy() {
|
||
const admin = db.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get();
|
||
if (admin) return admin.id;
|
||
const sys = db.prepare("SELECT id FROM users WHERE email='system@learnspace' OR email='admin@learnspace' LIMIT 1").get();
|
||
if (sys) return sys.id;
|
||
throw new Error('No admin user found — create one first via npm run seed');
|
||
}
|
||
|
||
/* ── Idempotent cleanup ─ */
|
||
function cleanupExistingExam9() {
|
||
const oldTestIds = db.prepare('SELECT test_id FROM exam9_variant_tests').all().map(r => r.test_id);
|
||
if (!oldTestIds.length) return 0;
|
||
|
||
// Delete questions linked only to exam9 tests (avoid touching shared questions)
|
||
const placeholders = oldTestIds.map(() => '?').join(',');
|
||
const orphanQuestions = db.prepare(`
|
||
SELECT DISTINCT q.id FROM questions q
|
||
JOIN test_questions tq ON tq.question_id = q.id
|
||
WHERE tq.test_id IN (${placeholders})
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM test_questions tq2
|
||
WHERE tq2.question_id = q.id
|
||
AND tq2.test_id NOT IN (${placeholders})
|
||
)
|
||
`).all(...oldTestIds, ...oldTestIds).map(r => r.id);
|
||
|
||
db.transaction(() => {
|
||
// Tests cascade-delete test_questions and exam9_variant_tests rows
|
||
db.prepare(`DELETE FROM tests WHERE id IN (${placeholders})`).run(...oldTestIds);
|
||
// Now delete orphaned questions (their options cascade)
|
||
if (orphanQuestions.length) {
|
||
const qph = orphanQuestions.map(() => '?').join(',');
|
||
db.prepare(`DELETE FROM questions WHERE id IN (${qph})`).run(...orphanQuestions);
|
||
}
|
||
})();
|
||
|
||
return oldTestIds.length;
|
||
}
|
||
|
||
/* ── Load a variant from its .js file via Function constructor ─ */
|
||
function loadVariant(n) {
|
||
const nn = String(n).padStart(2, '0');
|
||
const src = fs.readFileSync(path.join(VARIANTS_DIR, `v${nn}.js`), 'utf8');
|
||
const scope = {};
|
||
new Function('VARIANTS', src)(scope);
|
||
return scope[n];
|
||
}
|
||
|
||
/* ── Import a single variant ─ */
|
||
function importVariant(n, createdBy) {
|
||
const v = loadVariant(n);
|
||
if (!v || !Array.isArray(v.tasks) || !v.tasks.length)
|
||
throw new Error(`Variant ${n}: malformed (no tasks)`);
|
||
|
||
const testTitle = `Экзамен 9 — Вариант ${n}`;
|
||
const desc = `Экзаменационный вариант №${n} (математика, 9 класс). ${v.tasks.length} задач с разбором.`;
|
||
|
||
return db.transaction(() => {
|
||
const { lastInsertRowid: testId } = db.prepare(
|
||
`INSERT INTO tests (title, subject_slug, description, created_by, show_answers, time_limit)
|
||
VALUES (?, 'math', ?, ?, 1, NULL)`
|
||
).run(testTitle, desc, createdBy);
|
||
|
||
db.prepare('INSERT INTO exam9_variant_tests (variant, test_id) VALUES (?, ?)').run(n, testId);
|
||
|
||
const insQ = db.prepare(
|
||
`INSERT INTO questions (subject_id, text, type, correct_text, explanation, allow_html, source_type, year)
|
||
VALUES (?, ?, ?, ?, ?, 1, 'экзамен 9', 2025)`
|
||
);
|
||
const insOpt = db.prepare(
|
||
'INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?,?,?,?)'
|
||
);
|
||
const insTQ = db.prepare(
|
||
'INSERT INTO test_questions (test_id, question_id, order_index) VALUES (?,?,?)'
|
||
);
|
||
|
||
let questionCount = 0, optsCount = 0;
|
||
v.tasks.forEach((t, i) => {
|
||
const textWithFigure = t.figure ? `${t.text}<div class="task-figure" style="margin-top:12px">${t.figure}</div>` : t.text;
|
||
const explanation = t.sol || '';
|
||
|
||
if (t.opts && t.opts.length) {
|
||
const correctLetter = parseAnswerLetter(t.sol);
|
||
const { lastInsertRowid: qid } = insQ.run(
|
||
SUBJECT_ID, textWithFigure, 'single', null, explanation
|
||
);
|
||
t.opts.forEach(([letter, optText], idx) => {
|
||
const isCorrect = correctLetter && letter.toLowerCase() === correctLetter ? 1 : 0;
|
||
insOpt.run(qid, optText, isCorrect, idx);
|
||
optsCount++;
|
||
});
|
||
insTQ.run(testId, qid, i);
|
||
questionCount++;
|
||
} else {
|
||
const correctText = parseAnswer(t.sol);
|
||
const { lastInsertRowid: qid } = insQ.run(
|
||
SUBJECT_ID, textWithFigure, 'short_answer', correctText, explanation
|
||
);
|
||
insTQ.run(testId, qid, i);
|
||
questionCount++;
|
||
}
|
||
});
|
||
|
||
return { testId, questionCount, optsCount };
|
||
})();
|
||
}
|
||
|
||
/* ── Main ─ */
|
||
function main() {
|
||
const all = process.argv.includes('--all');
|
||
const variantNumbers = all
|
||
? Array.from({ length: 80 }, (_, i) => i + 1)
|
||
: Array.from({ length: 40 }, (_, i) => i * 2 + 1); // 1, 3, 5, ..., 79
|
||
|
||
console.log(`[exam9-import] Target: ${variantNumbers.length} variants (${all ? 'ALL' : 'ODD'})`);
|
||
|
||
const createdBy = findCreatedBy();
|
||
console.log(`[exam9-import] Owner user_id: ${createdBy}`);
|
||
|
||
const cleaned = cleanupExistingExam9();
|
||
if (cleaned) console.log(`[exam9-import] Cleaned ${cleaned} existing exam9 test(s)`);
|
||
|
||
let totalQ = 0, totalOpts = 0, errors = 0;
|
||
for (const n of variantNumbers) {
|
||
try {
|
||
const { questionCount, optsCount, testId } = importVariant(n, createdBy);
|
||
console.log(` v${String(n).padStart(2, '0')} → test #${testId} · ${questionCount} q · ${optsCount} opts`);
|
||
totalQ += questionCount;
|
||
totalOpts += optsCount;
|
||
} catch (e) {
|
||
console.error(` v${String(n).padStart(2, '0')} FAILED: ${e.message}`);
|
||
errors++;
|
||
}
|
||
}
|
||
|
||
console.log(`\n[exam9-import] Done: ${variantNumbers.length - errors}/${variantNumbers.length} variants, ${totalQ} questions, ${totalOpts} options`);
|
||
if (errors) process.exit(2);
|
||
}
|
||
|
||
main();
|