#!/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
patterns ─ */ function parseAnswer(sol) { if (!sol) return null; const m = sol.match(/
([\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();