diff --git a/backend/scripts/import-exam9.js b/backend/scripts/import-exam9.js
new file mode 100644
index 0000000..72271a7
--- /dev/null
+++ b/backend/scripts/import-exam9.js
@@ -0,0 +1,186 @@
+#!/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();
diff --git a/backend/src/controllers/sessionController.js b/backend/src/controllers/sessionController.js
index 22d9532..a8e800a 100644
--- a/backend/src/controllers/sessionController.js
+++ b/backend/src/controllers/sessionController.js
@@ -417,7 +417,7 @@ function loadQuestionsForSession(ids) {
const ph = _placeholders(ids.length);
const questions = db.prepare(
- `SELECT id, text, type, difficulty FROM questions WHERE id IN (${ph})`
+ `SELECT id, text, type, difficulty, allow_html, image FROM questions WHERE id IN (${ph})`
).all(...ids);
const allOptions = db.prepare(
@@ -449,7 +449,7 @@ function buildReview(session_id) {
const ph = _placeholders(ids.length);
const questions = db.prepare(
- `SELECT id, text, type, explanation, correct_text FROM questions WHERE id IN (${ph})`
+ `SELECT id, text, type, explanation, correct_text, allow_html, image FROM questions WHERE id IN (${ph})`
).all(...ids);
const answers = db.prepare(
diff --git a/backend/src/db/migrations/003_exam9_questions.sql b/backend/src/db/migrations/003_exam9_questions.sql
new file mode 100644
index 0000000..a2210de
--- /dev/null
+++ b/backend/src/db/migrations/003_exam9_questions.sql
@@ -0,0 +1,8 @@
+-- Allow HTML in question text and explanation (for curated content with formulas, SVG, etc.)
+ALTER TABLE questions ADD COLUMN allow_html INTEGER NOT NULL DEFAULT 0;
+
+-- Mapping: exam9 variant number → test_id (so teachers can assign a variant as homework)
+CREATE TABLE exam9_variant_tests (
+ variant INTEGER PRIMARY KEY,
+ test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE
+);
diff --git a/backend/src/routes/assignments.js b/backend/src/routes/assignments.js
index 0c98679..ff12ad2 100644
--- a/backend/src/routes/assignments.js
+++ b/backend/src/routes/assignments.js
@@ -31,10 +31,10 @@ const directSchema = { body: {
}};
const bulkSchema = { body: {
- title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
- class_id: { type: 'number', required: true, min: 1 },
- mode: { type: 'string', oneOf: MODES },
- count: { type: 'number', min: 1, max: 200 },
+ title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
+ class_ids: { type: 'array', required: true },
+ mode: { type: 'string', oneOf: MODES },
+ count: { type: 'number', min: 1, max: 200 },
}};
router.use(authMiddleware);
diff --git a/backend/src/routes/exam9.js b/backend/src/routes/exam9.js
new file mode 100644
index 0000000..7311c80
--- /dev/null
+++ b/backend/src/routes/exam9.js
@@ -0,0 +1,18 @@
+'use strict';
+const router = require('express').Router();
+const db = require('../db/db');
+const { authMiddleware } = require('../middleware/auth');
+
+router.use(authMiddleware);
+
+/* GET /api/exam9/variants — { variant: test_id } map for assigning variants as homework */
+router.get('/variants', (_req, res) => {
+ const rows = db.prepare(
+ 'SELECT evt.variant, evt.test_id FROM exam9_variant_tests evt JOIN tests t ON t.id = evt.test_id ORDER BY evt.variant'
+ ).all();
+ const map = {};
+ for (const r of rows) map[r.variant] = r.test_id;
+ res.json({ variants: map });
+});
+
+module.exports = router;
diff --git a/backend/src/server.js b/backend/src/server.js
index ebca0c6..552b1de 100644
--- a/backend/src/server.js
+++ b/backend/src/server.js
@@ -50,6 +50,7 @@ const petRoutes = require('./routes/pet');
const collectionRoutes = require('./routes/collection');
const redBookRoutes = require('./routes/red-book');
const parentRoutes = require('./routes/parent');
+const exam9Routes = require('./routes/exam9');
const { requestId, errorHandler } = require('./middleware/errorHandler');
@@ -166,6 +167,7 @@ app.use('/api/collection', collectionRoutes);
app.use('/api/red-book', redBookRoutes);
app.use('/api/biochem', require('./routes/biochem'));
app.use('/api/parent', parentRoutes);
+app.use('/api/exam9', exam9Routes);
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
const _featDb = require('./db/db');
diff --git a/frontend/exam9.html b/frontend/exam9.html
index e879b71..fe6ae7c 100644
--- a/frontend/exam9.html
+++ b/frontend/exam9.html
@@ -181,6 +181,76 @@
}
.ex-empty svg { width:48px; height:48px; opacity:.5; margin-bottom:14px; stroke:var(--text-3); }
+ /* ── Assign button + modal ── */
+ .ex-assign-row {
+ display:flex; align-items:center; gap:12px; margin-bottom:22px; flex-wrap:wrap;
+ }
+ .ex-assign-btn {
+ display:inline-flex; align-items:center; gap:7px;
+ padding:8px 16px; border:1.5px solid var(--violet); border-radius:10px;
+ background:rgba(155,93,229,.08); color:var(--violet);
+ font-family:'Manrope',sans-serif; font-size:.85rem; font-weight:700;
+ cursor:pointer; transition:all .15s;
+ }
+ .ex-assign-btn:hover { background:var(--violet); color:#fff; }
+ .ex-assign-btn:disabled {
+ opacity:.5; cursor:not-allowed; background:transparent; color:var(--text-3); border-color:var(--border);
+ }
+ .ex-assign-btn svg { width:14px; height:14px; }
+ .ex-assign-note {
+ font-size:.78rem; color:var(--text-3);
+ }
+
+ .ax-form { display:flex; flex-direction:column; gap:14px; }
+ .ax-field label {
+ display:block; font-size:.78rem; font-weight:700; color:var(--text-2);
+ text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;
+ }
+ .ax-classes {
+ display:flex; flex-direction:column; gap:6px; max-height:240px; overflow-y:auto;
+ border:1.5px solid var(--border); border-radius:10px; padding:8px;
+ }
+ .ax-class {
+ display:flex; align-items:center; gap:10px; padding:8px 10px;
+ border-radius:8px; cursor:pointer; transition:background .12s;
+ font-size:.9rem;
+ }
+ .ax-class:hover { background:var(--border); }
+ .ax-class input { accent-color:var(--violet); flex-shrink:0; }
+ .ax-class .ax-cname { font-weight:600; }
+ .ax-class .ax-cmeta { font-size:.78rem; color:var(--text-3); margin-left:auto; }
+ .ax-input {
+ width:100%; padding:9px 12px; border:1.5px solid var(--border-h);
+ border-radius:9px; background:var(--surface); color:var(--text);
+ font-family:'Manrope',sans-serif; font-size:.9rem;
+ }
+ .ax-input:focus { outline:none; border-color:var(--violet); }
+ .ax-actions {
+ display:flex; gap:10px; justify-content:flex-end; margin-top:6px;
+ }
+ .ax-btn {
+ padding:9px 18px; border-radius:10px; border:1.5px solid var(--border-h);
+ background:transparent; color:var(--text);
+ font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
+ cursor:pointer; transition:all .15s;
+ }
+ .ax-btn:hover { border-color:var(--text-2); }
+ .ax-btn-primary { background:var(--violet); border-color:var(--violet); color:#fff; }
+ .ax-btn-primary:hover { background:#7e3eca; border-color:#7e3eca; }
+ .ax-btn-primary:disabled { opacity:.5; cursor:not-allowed; }
+ .ax-error {
+ padding:9px 12px; border-radius:8px; background:rgba(241,91,68,.1);
+ border:1px solid rgba(241,91,68,.3); color:#F94144;
+ font-size:.84rem; display:none;
+ }
+ .ax-error.visible { display:block; }
+ .ax-success {
+ padding:9px 12px; border-radius:8px; background:rgba(6,214,160,.1);
+ border:1px solid rgba(6,214,160,.3); color:#06D6A0;
+ font-size:.84rem; display:none;
+ }
+ .ax-success.visible { display:block; }
+
@media (max-width: 600px) {
.ex-wrap { padding:20px 16px 60px; }
.ex-title { font-size:1.15rem; }
@@ -241,6 +311,33 @@
+