#!/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
([\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}
${t.figure}
` : 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();