d5fbd0168e
Реестр (registry.js) пополнен правами, которыми раньше нельзя было управлять: • Учитель: classroom.host (онлайн-уроки), livequiz.host (живые викторины), simbuilder.use (конструктор симуляций), flashcards.manage (общие колоды). • Ученик: homework.submit (сдача ДЗ), materials.save («Мои материалы»), assistant.use (ИИ-ассистент), games.play (учебные игры), flashcards.access / exam.access (доступ к разделам). Все default=1 → текущее поведение сохранено; админ может выключить по роли/классу/юзеру. Энфорс на роутах: учительские — requirePermission (роуты уже teacher-only); ученические на ОБЩИХ роутах (assistant/materials/games/flashcards/exam-prep) — новый requirePermissionForStudents(key) (учитель/админ проходят всегда, проверка только ученику — иначе isEnabled=false сломал бы учителя). PERM_DEFAULTS строится из реестра → фолбэк до сидирования = enabled, никто не блокируется. Группы UI — существующие (новых ярлыков нет). seedDefaults авто-сидит новые ключи на чтении. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1452 lines
59 KiB
JavaScript
1452 lines
59 KiB
JavaScript
'use strict';
|
||
const router = require('express').Router();
|
||
const db = require('../db/db');
|
||
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
|
||
const access = require('../services/contentAccess');
|
||
|
||
router.use(authMiddleware);
|
||
// Ролевой доступ к подготовке к экзаменам: ученик без права exam.access закрыт;
|
||
// учитель/админ проходят всегда. Видимость конкретных модулей — в «Доступ · контент».
|
||
router.use(requirePermissionForStudents('exam.access'));
|
||
|
||
/* Гейт доступа: любой маршрут с :examKey проверяется по allowlist.
|
||
Админ/учитель проходят всегда; ученик — только при наличии правила. */
|
||
router.param('examKey', (req, res, next, examKey) => {
|
||
if (!access.canAccessExam(req.user, examKey)) {
|
||
return res.status(403).json({ error: 'Нет доступа к этому экзамен-модулю' });
|
||
}
|
||
next();
|
||
});
|
||
|
||
/* ── Mock/variant picker: какие variant считаются «пробниками» ──────
|
||
ctmath: год-пачки (variant=год 2011–2024 и 0) — это тематический ПУЛ для
|
||
тренажёра по темам, а НЕ чистые 30-задачные варианты (у части до 114 задач).
|
||
Чистые варианты-пробники нумеруются 3-значно (101, 102, …), а год-пачки —
|
||
4-значными годами (≥2011) и 0, поэтому фильтр — ДИАПАЗОН [101;1999], а не
|
||
просто порог (год 2024 > 101 и иначе бы прошёл!). В пикере пробников,
|
||
mock/start и просмотре вариантов показываем только чистые. Тренажёр по темам
|
||
отбирает по subtopic и этот фильтр НЕ использует — пул задач не теряется.
|
||
Для остальных треков (math9: варианты 1..80) диапазона нет — показываются все. */
|
||
const MOCK_VARIANT_RANGE = { ctmath: [101, 1999] };
|
||
const isMockVariant = (examKey, v) => {
|
||
const r = MOCK_VARIANT_RANGE[examKey];
|
||
return r ? (v >= r[0] && v <= r[1]) : (v >= 1);
|
||
};
|
||
// Границы вариантов для практики/тренажёра: тот же фильтр, что у пикера, чтобы
|
||
// год-пачки (variant=год≥2011) и variant=0 НЕ попадали в выборку задач и не
|
||
// дублировали выверенные варианты-пробники. Для треков без диапазона — всё, кроме 0.
|
||
const MV_LO = ek => (MOCK_VARIANT_RANGE[ek] ? MOCK_VARIANT_RANGE[ek][0] : 1);
|
||
const MV_HI = ek => (MOCK_VARIANT_RANGE[ek] ? MOCK_VARIANT_RANGE[ek][1] : 1e15);
|
||
|
||
/* Человекочитаемая подпись варианта (номер в БД остаётся техническим, напр. 101).
|
||
Для ctmath варианты-пробники именуются по источнику; при добавлении новых
|
||
вариантов (104+) — дописывать сюда. Иначе fallback «Вариант N». */
|
||
const VARIANT_LABEL = {
|
||
ctmath: {
|
||
101: 'РТ-2024/25 · этап I',
|
||
102: 'РТ-2024/25 · этап II',
|
||
103: 'РТ-2024/25 · этап III',
|
||
104: 'РТ-2023/24 · этап I',
|
||
105: 'РТ-2023/24 · этап II',
|
||
106: 'РТ-2023/24 · этап III',
|
||
107: 'РТ-2022/23 · этап I',
|
||
108: 'РТ-2022/23 · этап II',
|
||
109: 'РТ-2022/23 · этап III',
|
||
110: 'ЦТ-2014',
|
||
111: 'ЦЭ-2024',
|
||
112: 'ЦТ-2015',
|
||
113: 'ЦТ-2016',
|
||
114: 'ЦТ-2018',
|
||
115: 'ЦТ-2019',
|
||
116: 'ЦТ-2020',
|
||
117: 'ЦТ-2021',
|
||
118: 'ЦТ-2017',
|
||
119: 'ЦТ-2013',
|
||
120: 'ЦТ-2012',
|
||
121: 'ЦТ-2011',
|
||
},
|
||
};
|
||
const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`;
|
||
|
||
/* ── Statements (prepared once) ────────────────────────────────── */
|
||
const SQL = {
|
||
listTracks: db.prepare(`
|
||
SELECT exam_key, title, subject_slug, grade, duration_min,
|
||
tasks_per_variant, variants_count, intro_html, sort_order
|
||
FROM exam_tracks
|
||
WHERE enabled = 1
|
||
ORDER BY sort_order, exam_key
|
||
`),
|
||
getTrack: db.prepare(`
|
||
SELECT exam_key, title, subject_slug, grade, duration_min,
|
||
tasks_per_variant, variants_count, scoring_json, intro_html
|
||
FROM exam_tracks
|
||
WHERE exam_key = ? AND enabled = 1
|
||
`),
|
||
countTasks: db.prepare(`
|
||
SELECT
|
||
COUNT(*) AS total,
|
||
COALESCE(SUM(CASE WHEN task_type = 'mc' THEN 1 ELSE 0 END), 0) AS mc,
|
||
COALESCE(SUM(CASE WHEN task_type = 'open' THEN 1 ELSE 0 END), 0) AS open,
|
||
COALESCE(SUM(CASE WHEN task_type = 'long' THEN 1 ELSE 0 END), 0) AS long
|
||
FROM exam_tasks
|
||
WHERE exam_key = ?
|
||
`),
|
||
/* For each (user, task) — was the most-recent attempt correct?
|
||
A task counts as «solved» iff at least one historical attempt is_correct = 1. */
|
||
userProgress: db.prepare(`
|
||
SELECT
|
||
COUNT(DISTINCT exam_task_id) AS tasks_attempted,
|
||
COUNT(DISTINCT CASE WHEN is_correct = 1 THEN exam_task_id END) AS tasks_solved,
|
||
COUNT(*) AS total_attempts,
|
||
COALESCE(SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct_attempts
|
||
FROM exam_attempts a
|
||
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||
WHERE a.user_id = ? AND t.exam_key = ?
|
||
`),
|
||
|
||
/* Variants list + per-user counts. One row per variant.
|
||
- total: tasks in variant
|
||
- solved: distinct tasks where user has ≥1 correct attempt
|
||
- viewed_sol: distinct tasks where user opened the solution */
|
||
listVariants: db.prepare(`
|
||
SELECT
|
||
t.variant,
|
||
COUNT(*) AS total,
|
||
COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN t.id END) AS solved,
|
||
COUNT(DISTINCT CASE WHEN a.solution_viewed = 1 THEN t.id END) AS viewed_sol
|
||
FROM exam_tasks t
|
||
LEFT JOIN exam_attempts a
|
||
ON a.exam_task_id = t.id AND a.user_id = ?
|
||
WHERE t.exam_key = ?
|
||
GROUP BY t.variant
|
||
ORDER BY t.variant
|
||
`),
|
||
|
||
getVariantTasks: db.prepare(`
|
||
SELECT
|
||
id, task_idx, task_type, text_html, figure_html, opts_json,
|
||
answer, solution_html, topic, subtopic, textbook_slug, textbook_paragraph
|
||
FROM exam_tasks
|
||
WHERE exam_key = ? AND variant = ?
|
||
ORDER BY task_idx
|
||
`),
|
||
|
||
/* Practice batch — RANDOM strategy.
|
||
Excludes long tasks (no auto-check) by default to keep UX consistent.
|
||
NOTE: kept for backwards compatibility / fallback. The primary random
|
||
path goes through pickRandomByDifficulty() below (difficulty-ordered
|
||
with optional topic exclusions). */
|
||
practiceRandom: db.prepare(`
|
||
SELECT
|
||
id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
||
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph
|
||
FROM exam_tasks
|
||
WHERE exam_key = ?
|
||
AND variant BETWEEN ? AND ?
|
||
AND task_type IN ('mc','open')
|
||
ORDER BY RANDOM()
|
||
LIMIT ?
|
||
`),
|
||
|
||
/* All subtopic slugs known for an exam — used to validate ?exclude=
|
||
so a bogus slug doesn't silently drop the filter. */
|
||
listSubtopicSlugs: db.prepare(`
|
||
SELECT slug FROM exam_topics
|
||
WHERE exam_key = ? AND parent_slug IS NOT NULL
|
||
`),
|
||
|
||
/* Topic reference map: subtopic_slug → textbook chapter / paragraph.
|
||
Loaded once and cached so we can stamp `topic_ref` onto every task
|
||
shape without an extra JOIN per query. */
|
||
listTopicRefs: db.prepare(`
|
||
SELECT slug, title, textbook_slug, textbook_paragraph
|
||
FROM exam_topics
|
||
WHERE exam_key = ? AND textbook_slug IS NOT NULL
|
||
`),
|
||
|
||
/* Practice batch — UNSOLVED strategy.
|
||
Excludes tasks the user has already solved correctly at least once. */
|
||
practiceUnsolved: db.prepare(`
|
||
SELECT
|
||
t.id, t.task_idx, t.variant, t.task_type, t.text_html, t.figure_html,
|
||
t.opts_json, t.answer, t.solution_html, t.topic, t.subtopic, t.difficulty,
|
||
t.textbook_slug, t.textbook_paragraph
|
||
FROM exam_tasks t
|
||
WHERE t.exam_key = ?
|
||
AND t.variant BETWEEN ? AND ?
|
||
AND t.task_type IN ('mc','open')
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM exam_attempts a
|
||
WHERE a.exam_task_id = t.id
|
||
AND a.user_id = ?
|
||
AND a.is_correct = 1
|
||
)
|
||
ORDER BY RANDOM()
|
||
LIMIT ?
|
||
`),
|
||
|
||
/* For attempt save: confirm the task belongs to a known track + user */
|
||
getTaskExamKey: db.prepare(`SELECT exam_key FROM exam_tasks WHERE id = ?`),
|
||
|
||
insertAttempt: db.prepare(`
|
||
INSERT INTO exam_attempts
|
||
(user_id, exam_task_id, user_answer, is_correct, time_ms,
|
||
mode, session_id, hint_used, solution_viewed, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`),
|
||
|
||
/* ── Mock sessions ──────────────────────────────────────────── */
|
||
getTasksByVariant: db.prepare(`
|
||
SELECT id FROM exam_tasks WHERE exam_key = ? AND variant = ? ORDER BY task_idx
|
||
`),
|
||
getRandomTaskIds: db.prepare(`
|
||
SELECT id FROM exam_tasks
|
||
WHERE exam_key = ? AND task_type IN ('mc','open')
|
||
ORDER BY RANDOM() LIMIT ?
|
||
`),
|
||
insertMockSession: db.prepare(`
|
||
INSERT INTO exam_mock_sessions
|
||
(user_id, exam_key, variant, source, task_ids_json,
|
||
started_at, duration_planned_min, total_tasks, status)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')
|
||
`),
|
||
getMockSession: db.prepare(`
|
||
SELECT id, user_id, exam_key, variant, source, task_ids_json,
|
||
started_at, finished_at, duration_planned_min,
|
||
score, total_correct, total_tasks, status
|
||
FROM exam_mock_sessions
|
||
WHERE id = ?
|
||
`),
|
||
getTracksScoring: db.prepare(`SELECT duration_min, scoring_json FROM exam_tracks WHERE exam_key = ?`),
|
||
getTasksByIds: (ids) => {
|
||
if (!ids.length) return [];
|
||
const ph = ids.map(() => '?').join(',');
|
||
return db.prepare(`
|
||
SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
||
answer, solution_html, topic, subtopic, textbook_slug, textbook_paragraph
|
||
FROM exam_tasks
|
||
WHERE id IN (${ph})
|
||
`).all(...ids);
|
||
},
|
||
/* Latest user-answer per task in a mock session (one row per task).
|
||
We upsert by deleting prior rows for (user, task, session, mode='mock'). */
|
||
deleteMockAnswer: db.prepare(`
|
||
DELETE FROM exam_attempts
|
||
WHERE user_id = ? AND exam_task_id = ? AND session_id = ? AND mode = 'mock'
|
||
`),
|
||
getMockAnswers: db.prepare(`
|
||
SELECT exam_task_id, user_answer, is_correct
|
||
FROM exam_attempts
|
||
WHERE user_id = ? AND session_id = ? AND mode = 'mock'
|
||
`),
|
||
updateMockAttemptCorrectness: db.prepare(`
|
||
UPDATE exam_attempts SET is_correct = ?
|
||
WHERE user_id = ? AND exam_task_id = ? AND session_id = ? AND mode = 'mock'
|
||
`),
|
||
finalizeMockSession: db.prepare(`
|
||
UPDATE exam_mock_sessions
|
||
SET status = 'finished', finished_at = ?, score = ?, total_correct = ?
|
||
WHERE id = ?
|
||
`),
|
||
|
||
/* ── Dashboard aggregates (F4) ──────────────────────────────── */
|
||
|
||
/* Distinct calendar dates (UTC) on which the user had ≥1 correct attempt
|
||
for tasks of a specific track, last 60 days. We compute streak from this in JS. */
|
||
streakDays: db.prepare(`
|
||
SELECT DISTINCT DATE(a.created_at / 1000, 'unixepoch') AS day
|
||
FROM exam_attempts a
|
||
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||
WHERE a.user_id = ? AND t.exam_key = ?
|
||
AND a.is_correct = 1
|
||
AND a.created_at >= ?
|
||
ORDER BY day DESC
|
||
`),
|
||
|
||
/* Accuracy over a recent time window. */
|
||
accuracyWindow: db.prepare(`
|
||
SELECT
|
||
COUNT(*) AS attempts,
|
||
COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct
|
||
FROM exam_attempts a
|
||
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||
WHERE a.user_id = ? AND t.exam_key = ?
|
||
AND a.is_correct IS NOT NULL
|
||
AND a.created_at >= ?
|
||
`),
|
||
|
||
/* Recent attempts (last N) with task metadata. */
|
||
recentAttempts: db.prepare(`
|
||
SELECT
|
||
a.id, a.exam_task_id, a.is_correct, a.solution_viewed, a.mode,
|
||
a.user_answer, a.created_at,
|
||
t.variant, t.task_idx, t.task_type, t.text_html
|
||
FROM exam_attempts a
|
||
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||
WHERE a.user_id = ? AND t.exam_key = ?
|
||
ORDER BY a.created_at DESC
|
||
LIMIT ?
|
||
`),
|
||
|
||
/* Activity heatmap — attempts per calendar day, last 28 days. */
|
||
activityHeatmap: db.prepare(`
|
||
SELECT
|
||
DATE(a.created_at / 1000, 'unixepoch') AS day,
|
||
COUNT(*) AS attempts,
|
||
COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct
|
||
FROM exam_attempts a
|
||
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||
WHERE a.user_id = ? AND t.exam_key = ?
|
||
AND a.created_at >= ?
|
||
GROUP BY day
|
||
ORDER BY day
|
||
`),
|
||
|
||
/* Recent mock sessions for "your last attempts" card on dashboard. */
|
||
recentMocks: db.prepare(`
|
||
SELECT id, variant, source, started_at, finished_at, status,
|
||
score, total_correct, total_tasks
|
||
FROM exam_mock_sessions
|
||
WHERE user_id = ? AND exam_key = ?
|
||
ORDER BY started_at DESC
|
||
LIMIT ?
|
||
`),
|
||
|
||
/* ── Study plan (F10) ───────────────────────────────────────── */
|
||
getPlan: db.prepare(`
|
||
SELECT exam_date, daily_target, weak_focus, created_at, updated_at
|
||
FROM exam_user_plan
|
||
WHERE user_id = ? AND exam_key = ?
|
||
`),
|
||
upsertPlan: db.prepare(`
|
||
INSERT INTO exam_user_plan
|
||
(user_id, exam_key, exam_date, daily_target, weak_focus, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(user_id, exam_key) DO UPDATE SET
|
||
exam_date = excluded.exam_date,
|
||
daily_target = excluded.daily_target,
|
||
weak_focus = excluded.weak_focus,
|
||
updated_at = excluded.updated_at
|
||
`),
|
||
deletePlan: db.prepare(`DELETE FROM exam_user_plan WHERE user_id = ? AND exam_key = ?`),
|
||
|
||
/* Tasks solved today (distinct, mc+open+long); used by "today" progress. */
|
||
tasksSolvedToday: db.prepare(`
|
||
SELECT COUNT(DISTINCT a.exam_task_id) AS solved
|
||
FROM exam_attempts a
|
||
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||
WHERE a.user_id = ? AND t.exam_key = ?
|
||
AND a.is_correct = 1
|
||
AND DATE(a.created_at / 1000, 'unixepoch') = DATE('now')
|
||
`),
|
||
|
||
/* ── Topics (F7) ────────────────────────────────────────────── */
|
||
|
||
listTopicsWithCounts: db.prepare(`
|
||
SELECT
|
||
tp.slug, tp.parent_slug, tp.title, tp.sort_order,
|
||
tp.textbook_slug, tp.textbook_paragraph,
|
||
COALESCE(stat.total, 0) AS total,
|
||
COALESCE(stat.attempted, 0) AS attempted,
|
||
COALESCE(stat.solved, 0) AS solved,
|
||
COALESCE(stat.attempts, 0) AS attempts,
|
||
COALESCE(stat.correct, 0) AS correct
|
||
FROM exam_topics tp
|
||
LEFT JOIN (
|
||
SELECT
|
||
t.subtopic,
|
||
COUNT(DISTINCT t.id) AS total,
|
||
COUNT(DISTINCT a.exam_task_id) AS attempted,
|
||
COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN a.exam_task_id END) AS solved,
|
||
COUNT(a.id) AS attempts,
|
||
COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct
|
||
FROM exam_tasks t
|
||
LEFT JOIN exam_attempts a
|
||
ON a.exam_task_id = t.id AND a.user_id = ?
|
||
WHERE t.exam_key = ? AND t.variant BETWEEN ? AND ? AND t.subtopic IS NOT NULL
|
||
GROUP BY t.subtopic
|
||
) stat ON stat.subtopic = tp.slug
|
||
WHERE tp.exam_key = ?
|
||
ORDER BY tp.sort_order
|
||
`),
|
||
|
||
/* Pick tasks for a topic practice session.
|
||
Excludes long tasks (no auto-check) and tasks the user has already solved
|
||
correctly, when ?exclude_solved=1. */
|
||
topicTasksUnsolved: db.prepare(`
|
||
SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
||
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph
|
||
FROM exam_tasks t
|
||
WHERE t.exam_key = ? AND t.variant BETWEEN ? AND ? AND t.subtopic = ?
|
||
AND t.task_type IN ('mc','open')
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM exam_attempts a
|
||
WHERE a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct = 1
|
||
)
|
||
ORDER BY RANDOM()
|
||
LIMIT ?
|
||
`),
|
||
|
||
topicTasksAny: db.prepare(`
|
||
SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
||
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph
|
||
FROM exam_tasks t
|
||
WHERE t.exam_key = ? AND t.variant BETWEEN ? AND ? AND t.subtopic = ?
|
||
AND t.task_type IN ('mc','open')
|
||
ORDER BY RANDOM()
|
||
LIMIT ?
|
||
`),
|
||
|
||
getTopicMeta: db.prepare(`
|
||
SELECT slug, parent_slug, title, description
|
||
FROM exam_topics
|
||
WHERE exam_key = ? AND slug = ?
|
||
`),
|
||
|
||
/* ── Weak topics (F8) ────────────────────────────────────────
|
||
Subtopics where the user has ≥3 attempts and accuracy < 60%.
|
||
Sorted by (accuracy ASC, attempts DESC). */
|
||
weakTopics: db.prepare(`
|
||
SELECT
|
||
tp.slug, tp.title, tp.parent_slug,
|
||
COUNT(a.id) AS attempts,
|
||
COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct,
|
||
COUNT(DISTINCT t.id) AS total_tasks,
|
||
COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN a.exam_task_id END) AS solved_tasks
|
||
FROM exam_topics tp
|
||
JOIN exam_tasks t ON t.subtopic = tp.slug AND t.exam_key = tp.exam_key
|
||
JOIN exam_attempts a ON a.exam_task_id = t.id
|
||
AND a.user_id = ?
|
||
AND a.is_correct IS NOT NULL
|
||
WHERE tp.exam_key = ? AND tp.parent_slug IS NOT NULL
|
||
GROUP BY tp.slug
|
||
HAVING attempts >= 3
|
||
AND (CAST(correct AS REAL) / attempts) < 0.6
|
||
ORDER BY (CAST(correct AS REAL) / attempts) ASC, attempts DESC
|
||
LIMIT 3
|
||
`),
|
||
|
||
weakTopicSlugs: db.prepare(`
|
||
SELECT tp.slug
|
||
FROM exam_topics tp
|
||
JOIN exam_tasks t ON t.subtopic = tp.slug AND t.exam_key = tp.exam_key
|
||
JOIN exam_attempts a ON a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct IS NOT NULL
|
||
WHERE tp.exam_key = ? AND tp.parent_slug IS NOT NULL
|
||
GROUP BY tp.slug
|
||
HAVING COUNT(a.id) >= 3
|
||
AND (CAST(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(a.id)) < 0.6
|
||
ORDER BY (CAST(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(a.id)) ASC,
|
||
COUNT(a.id) DESC
|
||
LIMIT 3
|
||
`),
|
||
|
||
/* Practice batch — WEAK strategy: pick random tasks from top-3 weak subtopics,
|
||
skipping tasks the user has already solved correctly. */
|
||
weakBatchTasks: (examKey, slugs, userId, count) => {
|
||
if (!slugs.length) return [];
|
||
const ph = slugs.map(() => '?').join(',');
|
||
return db.prepare(`
|
||
SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
||
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph
|
||
FROM exam_tasks t
|
||
WHERE t.exam_key = ?
|
||
AND t.variant BETWEEN ? AND ?
|
||
AND t.subtopic IN (${ph})
|
||
AND t.task_type IN ('mc','open')
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM exam_attempts a
|
||
WHERE a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct = 1
|
||
)
|
||
ORDER BY RANDOM()
|
||
LIMIT ?
|
||
`).all(examKey, MV_LO(examKey), MV_HI(examKey), ...slugs, userId, count);
|
||
},
|
||
};
|
||
|
||
/* ── GET /api/exam-prep/tracks ──
|
||
Public list of enabled exam tracks (for a future landing page). */
|
||
router.get('/tracks', (req, res) => {
|
||
const tracks = SQL.listTracks.all()
|
||
.filter(t => access.canAccessExam(req.user, t.exam_key));
|
||
res.json({ tracks });
|
||
});
|
||
|
||
/* ── Админ: управление экзамен-модулями (вкл/выкл) ──
|
||
Отдельные пути (без :examKey, чтобы не задеть гейт content_access). */
|
||
router.get('/admin/tracks', requireRole('admin'), (_req, res) => {
|
||
const tracks = db.prepare(`
|
||
SELECT exam_key, title, subject_slug, grade, enabled, variants_count, sort_order,
|
||
(SELECT COUNT(*) FROM exam_tasks t WHERE t.exam_key = exam_tracks.exam_key) AS task_count
|
||
FROM exam_tracks
|
||
ORDER BY sort_order, exam_key
|
||
`).all();
|
||
res.json({ tracks });
|
||
});
|
||
|
||
router.patch('/admin/track', requireRole('admin'), (req, res) => {
|
||
const key = String(req.body && req.body.exam_key || '').trim();
|
||
if (!key) return res.status(400).json({ error: 'exam_key required' });
|
||
if (!db.prepare('SELECT 1 FROM exam_tracks WHERE exam_key = ?').get(key))
|
||
return res.status(404).json({ error: 'Unknown exam track' });
|
||
const enabled = req.body && req.body.enabled ? 1 : 0;
|
||
db.prepare('UPDATE exam_tracks SET enabled = ? WHERE exam_key = ?').run(enabled, key);
|
||
res.json({ ok: true, exam_key: key, enabled });
|
||
});
|
||
|
||
/* ── GET /api/exam-prep/:examKey/info ──
|
||
Track metadata + global counts + this user's aggregate progress. */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.get('/:examKey/info', (req, res) => {
|
||
const { examKey } = req.params;
|
||
const track = SQL.getTrack.get(examKey);
|
||
if (!track) return res.status(404).json({ error: 'Unknown exam track' });
|
||
|
||
const counts = SQL.countTasks.get(examKey);
|
||
const progress = SQL.userProgress.get(req.user.id, examKey);
|
||
|
||
// Parse scoring grid (JSON in DB). Tolerant of malformed values.
|
||
let scoring = null;
|
||
if (track.scoring_json) {
|
||
try { scoring = JSON.parse(track.scoring_json); } catch { scoring = null; }
|
||
}
|
||
|
||
res.json({
|
||
track: {
|
||
exam_key: track.exam_key,
|
||
title: track.title,
|
||
subject: track.subject_slug,
|
||
grade: track.grade,
|
||
duration_min: track.duration_min,
|
||
tasks_per_variant: track.tasks_per_variant,
|
||
variants_count: track.variants_count,
|
||
intro_html: track.intro_html,
|
||
scoring,
|
||
},
|
||
counts,
|
||
progress,
|
||
});
|
||
});
|
||
|
||
/* ── GET /api/exam-prep/:examKey/variants ──
|
||
List of variants with per-user progress aggregates. */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.get('/:examKey/variants', (req, res) => {
|
||
const { examKey } = req.params;
|
||
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
|
||
const rows = SQL.listVariants.all(req.user.id, examKey).filter(r => isMockVariant(examKey, r.variant));
|
||
const variants = rows.map(r => ({
|
||
n: r.variant,
|
||
label: examVariantLabel(examKey, r.variant),
|
||
total: r.total,
|
||
solved: r.solved,
|
||
viewed_sol: r.viewed_sol,
|
||
}));
|
||
res.json({ variants });
|
||
});
|
||
|
||
/* ── GET /api/exam-prep/:examKey/variants/:n/tasks ──
|
||
All tasks of a specific variant. Includes answer + solution_html
|
||
(variants view = same UX as old /exam9: read condition + reveal solution).
|
||
In F3, the task-card will also use `answer` to auto-check user input. */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.get('/:examKey/variants/:n/tasks', (req, res) => {
|
||
const { examKey } = req.params;
|
||
const n = parseInt(req.params.n, 10);
|
||
if (!Number.isFinite(n) || n < 1) return res.status(400).json({ error: 'Bad variant number' });
|
||
if (!isMockVariant(examKey, n)) return res.status(404).json({ error: 'Variant not found or empty' });
|
||
|
||
const rows = SQL.getVariantTasks.all(examKey, n);
|
||
if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' });
|
||
|
||
const refMap = getTopicRefMap(examKey);
|
||
const tasks = rows.map(r => {
|
||
let topic_ref = null;
|
||
if (r.textbook_slug) {
|
||
const paraTitle = getParaTitle(r.textbook_slug, r.textbook_paragraph);
|
||
const fallbackTitle = r.subtopic ? (refMap.get(r.subtopic)?.title || null) : null;
|
||
topic_ref = {
|
||
title: paraTitle || fallbackTitle,
|
||
slug: r.textbook_slug,
|
||
paragraph: r.textbook_paragraph ?? null,
|
||
};
|
||
} else if (r.subtopic) {
|
||
topic_ref = refMap.get(r.subtopic) || null;
|
||
}
|
||
return {
|
||
id: r.id,
|
||
idx: r.task_idx,
|
||
type: r.task_type,
|
||
text: r.text_html,
|
||
figure: r.figure_html,
|
||
opts: r.opts_json ? safeJson(r.opts_json) : null,
|
||
answer: r.answer,
|
||
solution: r.solution_html,
|
||
topic: r.topic,
|
||
subtopic: r.subtopic,
|
||
topic_ref,
|
||
};
|
||
});
|
||
res.json({ variant: n, tasks });
|
||
});
|
||
|
||
function safeJson(s) { try { return JSON.parse(s); } catch { return null; } }
|
||
|
||
/* ── Topic-ref cache ─────────────────────────────────────────────
|
||
subtopic_slug → { title, textbook_slug, textbook_paragraph }.
|
||
Loaded lazily per examKey; invalidated never (migrations are the
|
||
only writer, and a server restart picks up new mappings). */
|
||
const _topicRefCache = new Map();
|
||
function getTopicRefMap(examKey) {
|
||
let map = _topicRefCache.get(examKey);
|
||
if (map) return map;
|
||
const rows = SQL.listTopicRefs.all(examKey);
|
||
map = new Map();
|
||
for (const r of rows) {
|
||
map.set(r.slug, {
|
||
title: r.title,
|
||
slug: r.textbook_slug,
|
||
paragraph: r.textbook_paragraph ?? null,
|
||
});
|
||
}
|
||
_topicRefCache.set(examKey, map);
|
||
return map;
|
||
}
|
||
|
||
function shapeTask(r, refMap) {
|
||
// Prefer task-level textbook over subtopic-level fallback.
|
||
let topic_ref = null;
|
||
if (r.textbook_slug) {
|
||
// Build title from para map, fall back to subtopic title via refMap.
|
||
const fallbackTitle = (refMap && r.subtopic) ? (refMap.get(r.subtopic)?.title || null) : null;
|
||
const paraTitle = getParaTitle(r.textbook_slug, r.textbook_paragraph);
|
||
topic_ref = {
|
||
title: paraTitle || fallbackTitle,
|
||
slug: r.textbook_slug,
|
||
paragraph: r.textbook_paragraph ?? null,
|
||
};
|
||
} else if (refMap && r.subtopic) {
|
||
topic_ref = refMap.get(r.subtopic) || null;
|
||
}
|
||
return {
|
||
id: r.id,
|
||
idx: r.task_idx,
|
||
variant: r.variant ?? null,
|
||
type: r.task_type,
|
||
text: r.text_html,
|
||
figure: r.figure_html,
|
||
opts: r.opts_json ? safeJson(r.opts_json) : null,
|
||
answer: r.answer,
|
||
solution: r.solution_html,
|
||
topic: r.topic ?? null,
|
||
subtopic: r.subtopic ?? null,
|
||
difficulty: r.difficulty ?? null,
|
||
topic_ref: topic_ref || null,
|
||
};
|
||
}
|
||
|
||
/* ── Para-title cache ─────────────────────────────────────────────
|
||
chapter_slug + paragraph_int → title string from g9_textbook_sections.json.
|
||
Built once on first call; covers all books 5-9. */
|
||
let _paraCache = null;
|
||
function _buildParaCache() {
|
||
const cache = new Map();
|
||
try {
|
||
const sections = require('../../scripts/data/g9_textbook_sections');
|
||
for (const s of sections) {
|
||
if (!s.para_id || !s.title) continue;
|
||
const m = String(s.para_id).match(/^p(\d+)$/);
|
||
if (!m) continue;
|
||
const n = parseInt(m[1], 10);
|
||
cache.set(`${s.chapter_slug}:${n}`, s.title);
|
||
}
|
||
} catch { /* if file missing, silently skip */ }
|
||
return cache;
|
||
}
|
||
|
||
function getParaTitle(chapterSlug, paraNum) {
|
||
if (!chapterSlug || paraNum == null) return null;
|
||
if (!_paraCache) _paraCache = _buildParaCache();
|
||
return _paraCache.get(`${chapterSlug}:${paraNum}`) || null;
|
||
}
|
||
|
||
/* ── Difficulty distribution for "Random" practice ───────────────
|
||
Returns an array [n1,n2,n3,n4,n5] of how many tasks of each
|
||
difficulty level (1..5) to pick, summing to N. Position 1 of the
|
||
batch is always difficulty 1 (so n1 >= 1 whenever N >= 1). */
|
||
function distributeByDifficulty(N) {
|
||
if (N <= 0) return [0, 0, 0, 0, 0];
|
||
if (N <= 5) {
|
||
// 1 task per level, then drop hardest first if N<5.
|
||
// Order [4,3,1,2] keeps slot 0 (difficulty 1) until last so the first
|
||
// task is always easiest.
|
||
const c = [1, 1, 1, 1, 1];
|
||
let drop = 5 - N;
|
||
const off = [4, 3, 1, 2];
|
||
for (let i = 0; drop > 0 && i < off.length; i++) { c[off[i]] = 0; drop--; }
|
||
return c;
|
||
}
|
||
// Weighted: 10% / 20% / 30% / 20% / 20%, min 1 per level so first slot = d1.
|
||
const w = [0.10, 0.20, 0.30, 0.20, 0.20];
|
||
const c = w.map(p => Math.max(1, Math.round(N * p)));
|
||
let total = c.reduce((a, b) => a + b, 0);
|
||
// adjust: trim from level 5 then 4, top-up at level 3
|
||
while (total > N) {
|
||
const idx = c[4] > 1 ? 4 : (c[3] > 1 ? 3 : c.indexOf(Math.max(...c)));
|
||
c[idx]--; total--;
|
||
}
|
||
while (total < N) { c[2]++; total++; }
|
||
return c;
|
||
}
|
||
|
||
/* Pick a difficulty-ordered batch with optional subtopic exclusions.
|
||
Excludes long tasks (no auto-check). Tasks come back already sorted
|
||
by difficulty ascending — the response order is the play order. */
|
||
function pickRandomByDifficulty(examKey, count, excludeSlugs) {
|
||
const dist = distributeByDifficulty(count);
|
||
const exParams = excludeSlugs.length ? excludeSlugs : null;
|
||
const exClause = exParams
|
||
? `AND (subtopic IS NULL OR subtopic NOT IN (${exParams.map(() => '?').join(',')}))`
|
||
: '';
|
||
const mvLo = MV_LO(examKey), mvHi = MV_HI(examKey);
|
||
|
||
const COLS = `id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
||
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph`;
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (let d = 1; d <= 5; d++) {
|
||
const limit = dist[d - 1];
|
||
if (limit === 0) continue;
|
||
const sql = `
|
||
SELECT ${COLS}
|
||
FROM exam_tasks
|
||
WHERE exam_key = ? AND variant BETWEEN ? AND ? AND task_type IN ('mc','open')
|
||
AND difficulty = ?
|
||
${exClause}
|
||
ORDER BY RANDOM()
|
||
LIMIT ?`;
|
||
const args = exParams
|
||
? [examKey, mvLo, mvHi, d, ...exParams, limit]
|
||
: [examKey, mvLo, mvHi, d, limit];
|
||
for (const r of db.prepare(sql).all(...args)) { if (!seen.has(r.id)) { seen.add(r.id); out.push(r); } }
|
||
}
|
||
// Backfill to `count` from any difficulty — covers tracks whose tasks don't
|
||
// span all 5 difficulty levels (otherwise empty levels would shrink the batch).
|
||
if (out.length < count) {
|
||
const ids = [...seen];
|
||
const notIn = ids.length ? `AND id NOT IN (${ids.map(() => '?').join(',')})` : '';
|
||
const sql = `
|
||
SELECT ${COLS}
|
||
FROM exam_tasks
|
||
WHERE exam_key = ? AND variant BETWEEN ? AND ? AND task_type IN ('mc','open')
|
||
${exClause}
|
||
${notIn}
|
||
ORDER BY RANDOM()
|
||
LIMIT ?`;
|
||
const args = [examKey, mvLo, mvHi, ...(exParams || []), ...ids, count - out.length];
|
||
out.push(...db.prepare(sql).all(...args));
|
||
}
|
||
out.sort((a, b) => (a.difficulty || 0) - (b.difficulty || 0));
|
||
return out;
|
||
}
|
||
|
||
/* Parse + validate ?exclude=slug1,slug2 against known subtopics for examKey. */
|
||
function parseExcludeParam(examKey, raw) {
|
||
if (!raw) return [];
|
||
const requested = String(raw).split(',').map(s => s.trim()).filter(Boolean);
|
||
if (!requested.length) return [];
|
||
const valid = new Set(SQL.listSubtopicSlugs.all(examKey).map(r => r.slug));
|
||
return requested.filter(s => valid.has(s));
|
||
}
|
||
|
||
/* ── GET /api/exam-prep/:examKey/practice/next ──
|
||
Returns up to ?count=N tasks for the practice trainer.
|
||
?strategy=random — any mc/open task, fresh random sample
|
||
=unsolved — only tasks the user has not yet solved correctly
|
||
Long tasks are excluded (no auto-check available). */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.get('/:examKey/practice/next', (req, res) => {
|
||
const { examKey } = req.params;
|
||
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
|
||
|
||
let count = Number(req.query.count) || 10;
|
||
count = Math.max(1, Math.min(count, 30));
|
||
|
||
const strategyRaw = req.query.strategy;
|
||
let strategy = ['unsolved', 'weak', 'random'].includes(strategyRaw) ? strategyRaw : 'random';
|
||
let rows;
|
||
let weakSlugs = null;
|
||
|
||
const excludeSlugs = parseExcludeParam(examKey, req.query.exclude);
|
||
|
||
if (strategy === 'weak') {
|
||
weakSlugs = SQL.weakTopicSlugs.all(req.user.id, examKey).map(r => r.slug);
|
||
if (weakSlugs.length === 0) {
|
||
// No weak topics yet → fall back to unsolved so the button still works
|
||
rows = SQL.practiceUnsolved.all(examKey, MV_LO(examKey), MV_HI(examKey), req.user.id, count);
|
||
strategy = 'unsolved-fallback';
|
||
} else {
|
||
rows = SQL.weakBatchTasks(examKey, weakSlugs, req.user.id, count);
|
||
if (!rows.length) {
|
||
// weak topics exist but all unsolved tasks exhausted → fallback to any from those topics
|
||
rows = SQL.weakBatchTasks(examKey, weakSlugs, -1 /* never matches */, count);
|
||
}
|
||
}
|
||
} else if (strategy === 'unsolved') {
|
||
rows = SQL.practiceUnsolved.all(examKey, MV_LO(examKey), MV_HI(examKey), req.user.id, count);
|
||
if (!rows.length) rows = SQL.practiceRandom.all(examKey, MV_LO(examKey), MV_HI(examKey), count);
|
||
} else {
|
||
// Random: difficulty-ordered batch, position 1 = difficulty 1.
|
||
rows = pickRandomByDifficulty(examKey, count, excludeSlugs);
|
||
// Fallback if exclusions wiped the pool — drop them and retry.
|
||
if (!rows.length && excludeSlugs.length) {
|
||
rows = pickRandomByDifficulty(examKey, count, []);
|
||
}
|
||
if (!rows.length) rows = SQL.practiceRandom.all(examKey, MV_LO(examKey), MV_HI(examKey), count);
|
||
}
|
||
|
||
const refMap = getTopicRefMap(examKey);
|
||
res.json({
|
||
strategy,
|
||
weak_slugs: weakSlugs,
|
||
excluded: excludeSlugs,
|
||
session_id: Date.now(),
|
||
tasks: rows.map(r => shapeTask(r, refMap)),
|
||
});
|
||
});
|
||
|
||
/* ── POST /api/exam-prep/attempts ──
|
||
Save a single attempt. Used by the variants view (F2) for "solution viewed"
|
||
markers, and by the practice / mock views (F3+) for actual answer checking.
|
||
|
||
Body: {
|
||
exam_task_id (required, integer)
|
||
user_answer (string|null) — what the user typed/selected
|
||
is_correct (0|1|null) — null for solution-viewed-only events
|
||
time_ms (integer|null)
|
||
mode ('practice'|'variant'|'topic'|'mock')
|
||
session_id (integer|null)
|
||
hint_used (0|1)
|
||
solution_viewed (0|1)
|
||
}
|
||
The server does NOT recompute is_correct from `user_answer` here — that's
|
||
the client's responsibility (it has the `answer` from /tasks). Server only
|
||
validates ownership and records the event. */
|
||
router.post('/attempts', (req, res) => {
|
||
const b = req.body || {};
|
||
const taskId = Number(b.exam_task_id);
|
||
if (!Number.isFinite(taskId)) return res.status(400).json({ error: 'exam_task_id required' });
|
||
|
||
const task = SQL.getTaskExamKey.get(taskId);
|
||
if (!task) return res.status(404).json({ error: 'Task not found' });
|
||
|
||
const mode = String(b.mode || '');
|
||
if (!['practice', 'variant', 'topic', 'mock'].includes(mode)) {
|
||
return res.status(400).json({ error: 'invalid mode' });
|
||
}
|
||
|
||
const userAnswer = b.user_answer != null ? String(b.user_answer).slice(0, 500) : null;
|
||
const isCorrect = b.is_correct === 1 || b.is_correct === 0 ? b.is_correct : null;
|
||
const timeMs = Number.isFinite(b.time_ms) ? Math.max(0, Math.min(b.time_ms, 24 * 60 * 60 * 1000)) : null;
|
||
const sessionId = Number.isFinite(b.session_id) ? b.session_id : null;
|
||
const hintUsed = b.hint_used ? 1 : 0;
|
||
const solutionViewed = b.solution_viewed ? 1 : 0;
|
||
|
||
const result = SQL.insertAttempt.run(
|
||
req.user.id, taskId, userAnswer, isCorrect, timeMs,
|
||
mode, sessionId, hintUsed, solutionViewed, Date.now()
|
||
);
|
||
|
||
// Variant-clear coin bonus: when the user finishes a variant in
|
||
// 'variant' mode with every task correctly answered, give them a
|
||
// one-shot 30-coin tip. Dedup key includes the variant number, so a
|
||
// user who later re-solves a different variant gets paid again.
|
||
if (mode === 'variant' && isCorrect === 1) {
|
||
try {
|
||
const taskMeta = db.prepare('SELECT variant FROM exam_tasks WHERE id = ?').get(taskId);
|
||
if (taskMeta?.variant) {
|
||
const stats = db.prepare(`
|
||
SELECT
|
||
(SELECT COUNT(*) FROM exam_tasks WHERE exam_key='math9' AND variant=?) AS total,
|
||
(SELECT COUNT(DISTINCT t.id)
|
||
FROM exam_attempts a
|
||
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||
WHERE a.user_id = ? AND t.variant = ? AND a.is_correct = 1) AS solved
|
||
`).get(taskMeta.variant, req.user.id, taskMeta.variant);
|
||
if (stats.total > 0 && stats.solved >= stats.total) {
|
||
const { awardCoinsOnce } = require('../controllers/gamification/service');
|
||
awardCoinsOnce(req.user.id, 30, `variant_clear:${taskMeta.variant}`, 'forever');
|
||
}
|
||
}
|
||
} catch (e) { /* don't break the response on bonus failure */ }
|
||
}
|
||
|
||
res.json({ id: Number(result.lastInsertRowid) });
|
||
});
|
||
|
||
/* ──────────────────────────────────────────────────────────────────
|
||
Dashboard (F4)
|
||
────────────────────────────────────────────────────────────────── */
|
||
|
||
/* Compute streak: consecutive calendar days ending today (or yesterday — a
|
||
one-day grace prevents the streak from being lost overnight) on which the
|
||
user had ≥1 correct attempt. Returns an integer >= 0.
|
||
`days` — array of date strings 'YYYY-MM-DD' sorted descending. */
|
||
function computeStreak(days) {
|
||
if (!days.length) return 0;
|
||
const set = new Set(days);
|
||
const dayMs = 86400000;
|
||
const today = new Date();
|
||
const todayStr = toIsoDate(today);
|
||
const yesterdayStr = toIsoDate(new Date(today.getTime() - dayMs));
|
||
|
||
// Anchor: start from today if today's correct exists, else yesterday (grace).
|
||
// If neither — streak is 0.
|
||
let cursor;
|
||
if (set.has(todayStr)) cursor = new Date(today);
|
||
else if (set.has(yesterdayStr)) cursor = new Date(today.getTime() - dayMs);
|
||
else return 0;
|
||
|
||
let streak = 0;
|
||
while (set.has(toIsoDate(cursor))) {
|
||
streak++;
|
||
cursor = new Date(cursor.getTime() - dayMs);
|
||
}
|
||
return streak;
|
||
}
|
||
|
||
function toIsoDate(d) {
|
||
const y = d.getUTCFullYear();
|
||
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
/* ── GET /api/exam-prep/:examKey/dashboard ──
|
||
Live aggregates for the dashboard view: streak, recent attempts,
|
||
accuracy over the last 7 days, 28-day activity heatmap, recent mocks. */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.get('/:examKey/dashboard', (req, res) => {
|
||
const { examKey } = req.params;
|
||
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
|
||
|
||
const now = Date.now();
|
||
const dayMs = 86400000;
|
||
const sixtyDaysAgo = now - 60 * dayMs;
|
||
const sevenDaysAgo = now - 7 * dayMs;
|
||
const twentyEightDaysAgo = now - 28 * dayMs;
|
||
|
||
const dayRows = SQL.streakDays.all(req.user.id, examKey, sixtyDaysAgo);
|
||
const streak = computeStreak(dayRows.map(r => r.day));
|
||
|
||
const acc7 = SQL.accuracyWindow.get(req.user.id, examKey, sevenDaysAgo);
|
||
const recent = SQL.recentAttempts.all(req.user.id, examKey, 8);
|
||
const heat = SQL.activityHeatmap.all(req.user.id, examKey, twentyEightDaysAgo);
|
||
const mocks = SQL.recentMocks.all(req.user.id, examKey, 3);
|
||
const weak = SQL.weakTopics.all(req.user.id, examKey);
|
||
|
||
res.json({
|
||
streak,
|
||
accuracy_7d: {
|
||
attempts: acc7.attempts,
|
||
correct: acc7.correct,
|
||
pct: acc7.attempts ? Math.round((acc7.correct / acc7.attempts) * 100) : null,
|
||
},
|
||
weak_topics: weak.map(w => ({
|
||
slug: w.slug,
|
||
title: w.title,
|
||
parent: w.parent_slug,
|
||
attempts: w.attempts,
|
||
correct: w.correct,
|
||
accuracy: Math.round((w.correct / w.attempts) * 100),
|
||
total_tasks: w.total_tasks,
|
||
solved_tasks: w.solved_tasks,
|
||
})),
|
||
recent_attempts: recent.map(r => ({
|
||
task_id: r.exam_task_id,
|
||
variant: r.variant,
|
||
task_idx: r.task_idx,
|
||
task_type: r.task_type,
|
||
is_correct: r.is_correct,
|
||
solution_viewed: r.solution_viewed,
|
||
mode: r.mode,
|
||
user_answer: r.user_answer,
|
||
preview: stripPreview(r.text_html),
|
||
created_at: r.created_at,
|
||
})),
|
||
heatmap: heat.map(h => ({ day: h.day, attempts: h.attempts, correct: h.correct })),
|
||
recent_mocks: mocks.map(m => ({
|
||
id: m.id, variant: m.variant, source: m.source,
|
||
started_at: m.started_at, finished_at: m.finished_at, status: m.status,
|
||
score: m.score, total_correct: m.total_correct, total_tasks: m.total_tasks,
|
||
})),
|
||
});
|
||
});
|
||
|
||
/* Lightweight preview: strip HTML, normalize LaTeX to readable unicode,
|
||
collapse whitespace, truncate. Used in dashboard "recent attempts" rows
|
||
where rendering KaTeX would be overkill but raw `$\dfrac{7}{9}$` is
|
||
ugly. Matches the spirit of tag-exam-tasks.js#stripText. */
|
||
function stripPreview(html) {
|
||
let text = String(html || '')
|
||
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
|
||
.replace(/<[^>]+>/g, ' ');
|
||
|
||
// Common LaTeX → unicode/plain. Order matters: handle fractions/roots
|
||
// before stripping bare backslash commands.
|
||
text = text
|
||
.replace(/\\d?frac\s*\{([^{}]+)\}\s*\{([^{}]+)\}/g, '$1/$2')
|
||
.replace(/\\sqrt\s*\{([^{}]+)\}/g, '√($1)')
|
||
.replace(/\\cdot/g, '·')
|
||
.replace(/\\times/g, '×')
|
||
.replace(/\\pm/g, '±')
|
||
.replace(/\\mp/g, '∓')
|
||
.replace(/\\leq?/g, '≤')
|
||
.replace(/\\geq?/g, '≥')
|
||
.replace(/\\neq/g, '≠')
|
||
.replace(/\\approx/g, '≈')
|
||
.replace(/\\infty/g, '∞')
|
||
.replace(/\\pi/g, 'π')
|
||
.replace(/\\alpha/g, 'α').replace(/\\beta/g, 'β').replace(/\\gamma/g, 'γ')
|
||
.replace(/\\(?:left|right)/g, '')
|
||
.replace(/\\[a-zA-Z]+\s*/g, ' ') // any remaining LaTeX command
|
||
.replace(/[{}]/g, '') // leftover braces
|
||
.replace(/\$+/g, '') // math delimiters
|
||
.replace(/&[a-z]+;/gi, ' ') // html entities
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
|
||
return text.length > 100 ? text.slice(0, 100) + '…' : text;
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────────────
|
||
Topics (F7) — list + per-topic practice batch
|
||
────────────────────────────────────────────────────────────────── */
|
||
|
||
/* ── GET /api/exam-prep/:examKey/topics ──
|
||
Returns sections (parents) with children + counts and user accuracy. */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.get('/:examKey/topics', (req, res) => {
|
||
const { examKey } = req.params;
|
||
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
|
||
|
||
const rows = SQL.listTopicsWithCounts.all(req.user.id, examKey, MV_LO(examKey), MV_HI(examKey), examKey);
|
||
|
||
// Build {sections: [{slug,title,subtopics:[...]}]}
|
||
const byParent = new Map();
|
||
const sections = [];
|
||
for (const r of rows) {
|
||
if (r.parent_slug == null) {
|
||
const section = { slug: r.slug, title: r.title, subtopics: [], total: 0, solved: 0 };
|
||
sections.push(section);
|
||
byParent.set(r.slug, section);
|
||
}
|
||
}
|
||
for (const r of rows) {
|
||
if (r.parent_slug != null) {
|
||
const sec = byParent.get(r.parent_slug);
|
||
if (!sec) continue;
|
||
const accuracy = r.attempts > 0 ? Math.round((r.correct / r.attempts) * 100) : null;
|
||
const solvedPct = r.total > 0 ? Math.round((r.solved / r.total) * 100) : 0;
|
||
sec.subtopics.push({
|
||
slug: r.slug,
|
||
title: r.title,
|
||
total: r.total,
|
||
solved: r.solved,
|
||
attempted: r.attempted,
|
||
attempts: r.attempts,
|
||
correct: r.correct,
|
||
textbook_slug: r.textbook_slug ?? null,
|
||
textbook_paragraph: r.textbook_paragraph ?? null,
|
||
accuracy,
|
||
solved_pct: solvedPct,
|
||
});
|
||
sec.total += r.total;
|
||
sec.solved += r.solved;
|
||
}
|
||
}
|
||
|
||
res.json({ sections });
|
||
});
|
||
|
||
/* ── GET /api/exam-prep/:examKey/topics/:slug/tasks ──
|
||
Returns a batch of tasks for topic practice.
|
||
Query: ?count=10 (5-30) ?exclude_solved=1 (default 1) */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.get('/:examKey/topics/:slug/tasks', (req, res) => {
|
||
const { examKey, slug } = req.params;
|
||
const meta = SQL.getTopicMeta.get(examKey, slug);
|
||
if (!meta) return res.status(404).json({ error: 'Topic not found' });
|
||
if (meta.parent_slug == null) {
|
||
return res.status(400).json({ error: 'Cannot fetch tasks for a section — pick a specific subtopic' });
|
||
}
|
||
|
||
let count = Number(req.query.count) || 10;
|
||
count = Math.max(5, Math.min(count, 30));
|
||
const excludeSolved = req.query.exclude_solved !== '0';
|
||
|
||
let rows;
|
||
if (excludeSolved) {
|
||
rows = SQL.topicTasksUnsolved.all(examKey, MV_LO(examKey), MV_HI(examKey), slug, req.user.id, count);
|
||
if (!rows.length) rows = SQL.topicTasksAny.all(examKey, MV_LO(examKey), MV_HI(examKey), slug, count);
|
||
} else {
|
||
rows = SQL.topicTasksAny.all(examKey, MV_LO(examKey), MV_HI(examKey), slug, count);
|
||
}
|
||
|
||
const refMap = getTopicRefMap(examKey);
|
||
res.json({
|
||
topic: { slug: meta.slug, title: meta.title, parent: meta.parent_slug },
|
||
session_id: Date.now(),
|
||
tasks: rows.map(r => shapeTask(r, refMap)),
|
||
});
|
||
});
|
||
|
||
/* ──────────────────────────────────────────────────────────────────
|
||
Study plan (F10) — by exam date
|
||
────────────────────────────────────────────────────────────────── */
|
||
|
||
function isIsoDate(s) {
|
||
return typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||
}
|
||
|
||
function daysBetween(yyyymmdd) {
|
||
// Calendar days from today to yyyymmdd (UTC). Negative if in past.
|
||
const [y, m, d] = yyyymmdd.split('-').map(Number);
|
||
const target = Date.UTC(y, m - 1, d);
|
||
const today = new Date();
|
||
const todayStart = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
|
||
return Math.round((target - todayStart) / 86400000);
|
||
}
|
||
|
||
function buildPlanPayload(row, userId, examKey) {
|
||
if (!row) return { plan: null };
|
||
|
||
const counts = SQL.countTasks.get(examKey);
|
||
const progress = SQL.userProgress.get(userId, examKey);
|
||
const todayRow = SQL.tasksSolvedToday.get(userId, examKey);
|
||
|
||
const daysLeft = daysBetween(row.exam_date);
|
||
const tasksLeft = Math.max(0, counts.total - progress.tasks_solved);
|
||
|
||
// Auto daily target if user hasn't customized: ceil(left / days)
|
||
let dailyTarget = row.daily_target;
|
||
if (!dailyTarget && daysLeft > 0) {
|
||
dailyTarget = Math.max(5, Math.min(50, Math.ceil(tasksLeft / daysLeft)));
|
||
}
|
||
|
||
return {
|
||
plan: {
|
||
exam_date: row.exam_date,
|
||
daily_target: dailyTarget,
|
||
weak_focus: !!row.weak_focus,
|
||
created_at: row.created_at,
|
||
updated_at: row.updated_at,
|
||
days_left: daysLeft,
|
||
tasks_left: tasksLeft,
|
||
today: {
|
||
solved: todayRow.solved,
|
||
target: dailyTarget,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
/* ── GET /api/exam-prep/:examKey/plan ──
|
||
Returns { plan: null } if user has no plan for this track. */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.get('/:examKey/plan', (req, res) => {
|
||
const { examKey } = req.params;
|
||
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
|
||
const row = SQL.getPlan.get(req.user.id, examKey);
|
||
res.json(buildPlanPayload(row, req.user.id, examKey));
|
||
});
|
||
|
||
/* ── PUT /api/exam-prep/:examKey/plan ──
|
||
Body: { exam_date: 'YYYY-MM-DD', daily_target?: int 5-50, weak_focus?: 0|1 }
|
||
Upserts the plan. exam_date is required; the other fields are optional. */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.put('/:examKey/plan', (req, res) => {
|
||
const { examKey } = req.params;
|
||
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
|
||
|
||
const examDate = req.body?.exam_date;
|
||
if (!isIsoDate(examDate)) return res.status(400).json({ error: 'exam_date must be YYYY-MM-DD' });
|
||
|
||
let dailyTarget = req.body?.daily_target;
|
||
if (dailyTarget != null) {
|
||
dailyTarget = Number(dailyTarget);
|
||
if (!Number.isInteger(dailyTarget) || dailyTarget < 5 || dailyTarget > 50) {
|
||
return res.status(400).json({ error: 'daily_target must be integer 5-50' });
|
||
}
|
||
} else {
|
||
dailyTarget = 10; // sensible default; client can override
|
||
}
|
||
|
||
const weakFocus = req.body?.weak_focus ? 1 : 0;
|
||
|
||
const existing = SQL.getPlan.get(req.user.id, examKey);
|
||
const now = Date.now();
|
||
SQL.upsertPlan.run(
|
||
req.user.id, examKey, examDate, dailyTarget, weakFocus,
|
||
existing ? existing.created_at : now, now
|
||
);
|
||
|
||
const row = SQL.getPlan.get(req.user.id, examKey);
|
||
res.json(buildPlanPayload(row, req.user.id, examKey));
|
||
});
|
||
|
||
/* ── DELETE /api/exam-prep/:examKey/plan ──
|
||
Remove the user's plan for this track. */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.delete('/:examKey/plan', (req, res) => {
|
||
const { examKey } = req.params;
|
||
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
|
||
SQL.deletePlan.run(req.user.id, examKey);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
/* ──────────────────────────────────────────────────────────────────
|
||
Mock exam (F9)
|
||
────────────────────────────────────────────────────────────────── */
|
||
|
||
/* Convert {correct} count into a final score per the track's scoring grid.
|
||
Grid is JSON like [{correct:30,score:10},{correct:27,score:9},...].
|
||
Sorted descending; we return the score for the largest threshold met. */
|
||
function scoreFromGrid(correctCount, scoringJson) {
|
||
if (!scoringJson) return null;
|
||
let grid;
|
||
try { grid = JSON.parse(scoringJson); } catch { return null; }
|
||
if (!Array.isArray(grid) || !grid.length) return null;
|
||
grid = grid.slice().sort((a, b) => b.correct - a.correct);
|
||
for (const entry of grid) {
|
||
if (correctCount >= entry.correct) return entry.score;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* ── POST /api/exam-prep/:examKey/mock/start ──
|
||
Body: { source: 'variant'|'random', variant?: number, count?: number }
|
||
Creates an active mock session and returns its id. */
|
||
router.post('/:examKey/mock/start', (req, res) => {
|
||
const { examKey } = req.params;
|
||
const track = SQL.getTrack.get(examKey);
|
||
if (!track) return res.status(404).json({ error: 'Unknown exam track' });
|
||
|
||
const source = req.body?.source;
|
||
let taskIds = [];
|
||
let variant = null;
|
||
|
||
if (source === 'variant') {
|
||
variant = Number(req.body?.variant);
|
||
if (!Number.isInteger(variant) || !isMockVariant(examKey, variant)) {
|
||
return res.status(400).json({ error: 'Variant number required' });
|
||
}
|
||
const rows = SQL.getTasksByVariant.all(examKey, variant);
|
||
if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' });
|
||
taskIds = rows.map(r => r.id);
|
||
} else if (source === 'random') {
|
||
let count = Number(req.body?.count) || track.tasks_per_variant;
|
||
count = Math.max(5, Math.min(count, 30));
|
||
const rows = SQL.getRandomTaskIds.all(examKey, count);
|
||
taskIds = rows.map(r => r.id);
|
||
} else {
|
||
return res.status(400).json({ error: 'source must be variant|random' });
|
||
}
|
||
|
||
const r = SQL.insertMockSession.run(
|
||
req.user.id, examKey, variant, source, JSON.stringify(taskIds),
|
||
Date.now(), track.duration_min, taskIds.length
|
||
);
|
||
res.json({
|
||
id: Number(r.lastInsertRowid),
|
||
task_count: taskIds.length,
|
||
duration_min: track.duration_min,
|
||
});
|
||
});
|
||
|
||
/* ── GET /api/exam-prep/mock/:id ──
|
||
Returns mock-session state + tasks.
|
||
- status='active' : tasks WITHOUT answer/solution; includes user_answers map
|
||
- status='finished' : tasks WITH answer/solution; includes is_correct per task */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.get('/mock/:id', (req, res) => {
|
||
const id = Number(req.params.id);
|
||
const sess = SQL.getMockSession.get(id);
|
||
if (!sess) return res.status(404).json({ error: 'Mock session not found' });
|
||
if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
|
||
|
||
let ids = [];
|
||
try { ids = JSON.parse(sess.task_ids_json) || []; } catch {}
|
||
const tasksRaw = SQL.getTasksByIds(ids);
|
||
const taskMap = new Map(tasksRaw.map(t => [t.id, t]));
|
||
const tasks = ids.map(i => taskMap.get(i)).filter(Boolean);
|
||
|
||
const answers = SQL.getMockAnswers.all(req.user.id, id);
|
||
const answerByTask = new Map(answers.map(a => [a.exam_task_id, a]));
|
||
|
||
const isActive = sess.status === 'active';
|
||
const out = tasks.map((t, idx) => {
|
||
const ua = answerByTask.get(t.id);
|
||
return {
|
||
id: t.id,
|
||
idx: idx + 1,
|
||
variant: t.variant,
|
||
type: t.task_type,
|
||
text: t.text_html,
|
||
figure: t.figure_html,
|
||
opts: t.opts_json ? safeJson(t.opts_json) : null,
|
||
// Hide answer/solution while active
|
||
answer: isActive ? null : t.answer,
|
||
solution: isActive ? null : t.solution_html,
|
||
// User's stored answer + correctness (post-finish only)
|
||
user_answer: ua?.user_answer ?? null,
|
||
is_correct: !isActive ? (ua?.is_correct ?? null) : null,
|
||
};
|
||
});
|
||
|
||
res.json({
|
||
session: {
|
||
id: sess.id,
|
||
exam_key: sess.exam_key,
|
||
variant: sess.variant,
|
||
variant_label: sess.variant != null ? examVariantLabel(sess.exam_key, sess.variant) : null,
|
||
source: sess.source,
|
||
status: sess.status,
|
||
started_at: sess.started_at,
|
||
finished_at: sess.finished_at,
|
||
duration_planned_min: sess.duration_planned_min,
|
||
score: sess.score,
|
||
total_correct: sess.total_correct,
|
||
total_tasks: sess.total_tasks,
|
||
},
|
||
tasks: out,
|
||
});
|
||
});
|
||
|
||
/* ── POST /api/exam-prep/mock/:id/answer ──
|
||
Body: { exam_task_id, user_answer }
|
||
Upserts the user's draft answer (no correctness check while active). */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.post('/mock/:id/answer', (req, res) => {
|
||
const id = Number(req.params.id);
|
||
const sess = SQL.getMockSession.get(id);
|
||
if (!sess) return res.status(404).json({ error: 'Mock session not found' });
|
||
if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
|
||
if (sess.status !== 'active') return res.status(409).json({ error: 'Session finished' });
|
||
|
||
const taskId = Number(req.body?.exam_task_id);
|
||
if (!Number.isFinite(taskId)) return res.status(400).json({ error: 'exam_task_id required' });
|
||
|
||
let ids = [];
|
||
try { ids = JSON.parse(sess.task_ids_json) || []; } catch {}
|
||
if (!ids.includes(taskId)) return res.status(400).json({ error: 'Task not in this session' });
|
||
|
||
const userAnswer = req.body?.user_answer != null ? String(req.body.user_answer).slice(0, 500) : null;
|
||
const timeMs = Number.isFinite(req.body?.time_ms) ? Math.max(0, Math.min(req.body.time_ms, 24 * 3600 * 1000)) : null;
|
||
|
||
/* Upsert: delete any prior mock-attempt for this task, then insert fresh */
|
||
db.transaction(() => {
|
||
SQL.deleteMockAnswer.run(req.user.id, taskId, id);
|
||
SQL.insertAttempt.run(
|
||
req.user.id, taskId, userAnswer, null /* is_correct unknown */,
|
||
timeMs, 'mock', id, 0, 0, Date.now()
|
||
);
|
||
})();
|
||
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
/* ── POST /api/exam-prep/mock/:id/finish ──
|
||
Computes correctness for every stored answer + final score. */
|
||
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||
router.post('/mock/:id/finish', (req, res) => {
|
||
const id = Number(req.params.id);
|
||
const sess = SQL.getMockSession.get(id);
|
||
if (!sess) return res.status(404).json({ error: 'Mock session not found' });
|
||
if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
|
||
if (sess.status === 'finished') return res.json({ ok: true, already: true });
|
||
|
||
// Pull tasks + answers, compute correctness per task using same logic as client.
|
||
let ids = [];
|
||
try { ids = JSON.parse(sess.task_ids_json) || []; } catch {}
|
||
const tasks = SQL.getTasksByIds(ids);
|
||
const taskMap = new Map(tasks.map(t => [t.id, t]));
|
||
const answers = SQL.getMockAnswers.all(req.user.id, id);
|
||
|
||
let totalCorrect = 0;
|
||
db.transaction(() => {
|
||
for (const a of answers) {
|
||
const t = taskMap.get(a.exam_task_id);
|
||
if (!t || !t.answer) continue;
|
||
const correct = checkAnswerServer(a.user_answer, t.answer) ? 1 : 0;
|
||
if (correct) totalCorrect++;
|
||
SQL.updateMockAttemptCorrectness.run(correct, req.user.id, a.exam_task_id, id);
|
||
}
|
||
})();
|
||
|
||
const track = SQL.getTracksScoring.get(sess.exam_key);
|
||
const score = track ? scoreFromGrid(totalCorrect, track.scoring_json) : null;
|
||
|
||
SQL.finalizeMockSession.run(Date.now(), score, totalCorrect, id);
|
||
|
||
res.json({
|
||
ok: true,
|
||
total_correct: totalCorrect,
|
||
total_tasks: sess.total_tasks,
|
||
score,
|
||
});
|
||
});
|
||
|
||
/* ──────────────────────────────────────────────────────────────────
|
||
Server-side answer checker — mirror of frontend EP.answer.check.
|
||
Kept here (intentional code duplication) so the server is the source
|
||
of truth for mock-exam scoring and can never be bypassed by a
|
||
tampered client.
|
||
────────────────────────────────────────────────────────────────── */
|
||
const EPS = 1e-6;
|
||
|
||
function srvToNumber(s) {
|
||
if (s == null) return NaN;
|
||
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
|
||
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
|
||
if (f) {
|
||
const num = Number(f[1]);
|
||
const den = Number(f[2]);
|
||
if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return NaN;
|
||
return num / den;
|
||
}
|
||
const n = Number(t);
|
||
return Number.isFinite(n) ? n : NaN;
|
||
}
|
||
|
||
function srvToPair(s) {
|
||
if (s == null) return null;
|
||
const t = String(s).trim().replace(/\$/g, '').replace(/\s+и\s+/g, ';');
|
||
const parts = t.split(/[;,]/).map(p => p.trim()).filter(Boolean);
|
||
if (parts.length !== 2) return null;
|
||
const a = srvToNumber(parts[0]);
|
||
const b = srvToNumber(parts[1]);
|
||
if (Number.isNaN(a) || Number.isNaN(b)) return null;
|
||
return a <= b ? [a, b] : [b, a];
|
||
}
|
||
|
||
function checkAnswerServer(userInput, canonical) {
|
||
if (userInput == null || canonical == null) return false;
|
||
const c = String(canonical).trim();
|
||
if (/^[а-д]$/.test(c)) {
|
||
return String(userInput).trim().toLowerCase() === c.toLowerCase();
|
||
}
|
||
if (/^[^;]+;[^;]+$/.test(c)) {
|
||
const cp = srvToPair(c);
|
||
const up = srvToPair(userInput);
|
||
if (!cp || !up) return false;
|
||
return Math.abs(cp[0] - up[0]) < EPS && Math.abs(cp[1] - up[1]) < EPS;
|
||
}
|
||
const cn = srvToNumber(c);
|
||
const un = srvToNumber(userInput);
|
||
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
|
||
return Math.abs(cn - un) < EPS;
|
||
}
|
||
|
||
module.exports = router;
|