/* global window */ // ============================================================== // utils.js — pure helpers: normalization, grading, formatting // ============================================================== const normalizeString = (s, opts = {}) => { if (s == null) return ''; let out = String(s); if (!opts.caseSensitive) out = out.toLowerCase(); if (opts.ignoreAccents !== false) { out = out.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } if (opts.ignorePunctuation !== false) { out = out.replace(/[.,/#!$%^&*;:{}=\-_`~()¿?¡!'"]/g, ''); } out = out.replace(/\s+/g, ' ').trim(); return out; }; const normalizeLatex = (s) => { if (s == null) return ''; return String(s) .replace(/\s+/g, '') .replace(/\\left|\\right/g, '') .replace(/\\,|\\;|\\:|\\!|\\ /g, '') .replace(/\{([^{}])\}/g, '$1') // strip braces around single chars .replace(/\\cdot/g, '*') .replace(/\\times/g, '*') .replace(/\\ln/g, 'ln') .replace(/\\log/g, 'log') .toLowerCase(); }; const arraysEqualUnordered = (a, b) => { if (!Array.isArray(a) || !Array.isArray(b)) return false; if (a.length !== b.length) return false; const sa = [...a].sort(); const sb = [...b].sort(); return sa.every((v, i) => v === sb[i]); }; const arraysEqualOrdered = (a, b) => { if (!Array.isArray(a) || !Array.isArray(b)) return false; if (a.length !== b.length) return false; return a.every((v, i) => v === b[i]); }; // ---- grading ---- const gradeQuestion = (q, userAnswer) => { if (userAnswer == null) return { status: 'unanswered', correct: false, points: 0 }; switch (q.type) { case 'multiple-choice': { const ok = userAnswer === q.answer; return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 }; } case 'multiple-select': { const ok = arraysEqualUnordered(userAnswer || [], q.answer || []); return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 }; } case 'short-answer': case 'translation': { const opts = { caseSensitive: q.caseSensitive === true, ignorePunctuation: q.ignorePunctuation !== false, ignoreAccents: q.ignoreAccents !== false, }; const target = normalizeString(userAnswer, opts); const accepted = [q.answer, ...(q.acceptedForms || [])] .filter(Boolean) .map((a) => normalizeString(a, opts)); const ok = accepted.includes(target); return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 }; } case 'math-input': { const target = normalizeLatex(userAnswer); const accepted = [q.answer, ...(q.acceptedForms || [])].filter(Boolean).map(normalizeLatex); const ok = accepted.includes(target); return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 }; } case 'match': { const answer = q.answer || {}; const given = userAnswer || {}; const keys = Object.keys(answer); if (keys.length === 0) return { status: 'unanswered', correct: false, points: 0 }; const allOk = keys.every((k) => given[k] === answer[k]); return { status: allOk ? 'correct' : 'wrong', correct: allOk, points: allOk ? (q.points || 1) : 0 }; } case 'order': { const ok = arraysEqualOrdered(userAnswer || [], q.answer || []); return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 }; } case 'essay': { // self-graded; default to ungraded if (userAnswer && userAnswer.selfGrade != null) { const g = userAnswer.selfGrade; return { status: g >= 0.5 ? 'correct' : 'wrong', correct: g >= 0.5, points: Math.round((q.points || 1) * g) }; } return { status: 'ungraded', correct: false, points: 0 }; } default: return { status: 'unanswered', correct: false, points: 0 }; } }; // ---- time formatting ---- const formatTime = (ms) => { if (ms == null || ms < 0) ms = 0; const totalSec = Math.floor(ms / 1000); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; const pad = (n) => String(n).padStart(2, '0'); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`; }; const formatDate = (ts) => { const d = new Date(ts); const now = new Date(); const diff = now - d; if (diff < 60_000) return 'just now'; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); }; // ---- LaTeX rendering (safe wrapper around KaTeX) ---- // Renders inline $...$ and display $$...$$ within a plain string. const renderLatex = (text) => { if (!text) return ''; if (!window.katex) return escapeHtml(text); const parts = []; let rest = String(text); // First handle $$...$$ (display) const displayRe = /\$\$([\s\S]+?)\$\$/g; rest = rest.replace(displayRe, (_, tex) => { try { return `${window.katex.renderToString(tex, { displayMode: true, throwOnError: false })}`; } catch { return `${escapeHtml(tex)}`; } }); // Then $...$ (inline) const inlineRe = /\$([^$\n]+?)\$/g; rest = rest.replace(inlineRe, (_, tex) => { try { return window.katex.renderToString(tex, { displayMode: false, throwOnError: false }); } catch { return `${escapeHtml(tex)}`; } }); return rest; }; const escapeHtml = (s) => String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Component that renders a string with LaTeX + preserves newlines as
const Tex = ({ children, tag: Tag = 'span', className }) => { const html = String(children || '').split('\n').map(renderLatex).join('
'); return React.createElement(Tag, { className, dangerouslySetInnerHTML: { __html: html }, }); }; // ---- simple syntax highlighter (for code blocks) ---- const highlightCode = (code, lang) => { const lines = escapeHtml(code); const kw = /\b(function|return|if|else|const|let|var|for|while|import|export|from|class|new|this|def|print|in|not|and|or|True|False|None|null|undefined|true|false)\b/g; const str = /("[^&]*?"|'[^&]*?'|`[^`]*?`)/g; const num = /\b(\d+(?:\.\d+)?)\b/g; const com = /(\/\/[^\n]*|#[^\n]*)/g; const fn = /\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/g; return lines .replace(com, '$1') .replace(str, '$1') .replace(kw, '$1') .replace(num, '$1') .replace(fn, '$1'); }; // ---- persistence ---- const STORAGE_KEY = 'brainwave.exam.state'; const HISTORY_KEY = 'brainwave.exam.history'; const loadState = () => { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch { return null; } }; const saveState = (state) => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {} }; const clearState = () => { try { localStorage.removeItem(STORAGE_KEY); } catch {} }; const loadHistory = () => { try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; } }; const appendHistory = (entry) => { try { const hist = loadHistory(); hist.unshift(entry); localStorage.setItem(HISTORY_KEY, JSON.stringify(hist.slice(0, 30))); } catch {} }; const clearHistory = () => { try { localStorage.removeItem(HISTORY_KEY); } catch {} }; // ---- validate exam JSON ---- const validateExam = (data) => { if (!data || typeof data !== 'object') return 'Not a JSON object'; if (!data.title) return 'Missing "title"'; if (!Array.isArray(data.questions) || data.questions.length === 0) return 'Missing "questions" array'; for (const q of data.questions) { if (!q.id) return `Question missing id`; if (!q.type) return `Question "${q.id}" missing type`; if (!q.prompt) return `Question "${q.id}" missing prompt`; } return null; }; // Export to window Object.assign(window, { normalizeString, normalizeLatex, arraysEqualUnordered, arraysEqualOrdered, gradeQuestion, formatTime, formatDate, renderLatex, escapeHtml, Tex, highlightCode, loadState, saveState, clearState, loadHistory, appendHistory, clearHistory, validateExam, });