Public Access
- Introduced a new favicon.svg file for the application. - Added icons.svg containing various SVG symbols for UI components. - Created tsconfig.app.json for frontend TypeScript configuration with ES2023 target and bundler mode. - Established tsconfig.json to reference app and node configurations. - Implemented tsconfig.node.json for Node.js specific TypeScript settings.
235 lines
8.4 KiB
JavaScript
235 lines
8.4 KiB
JavaScript
/* 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 `<span class="katex-display-wrap">${window.katex.renderToString(tex, { displayMode: true, throwOnError: false })}</span>`;
|
|
} catch {
|
|
return `<code>${escapeHtml(tex)}</code>`;
|
|
}
|
|
});
|
|
// Then $...$ (inline)
|
|
const inlineRe = /\$([^$\n]+?)\$/g;
|
|
rest = rest.replace(inlineRe, (_, tex) => {
|
|
try {
|
|
return window.katex.renderToString(tex, { displayMode: false, throwOnError: false });
|
|
} catch {
|
|
return `<code>${escapeHtml(tex)}</code>`;
|
|
}
|
|
});
|
|
return rest;
|
|
};
|
|
|
|
const escapeHtml = (s) =>
|
|
String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
|
|
// Component that renders a string with LaTeX + preserves newlines as <br>
|
|
const Tex = ({ children, tag: Tag = 'span', className }) => {
|
|
const html = String(children || '').split('\n').map(renderLatex).join('<br/>');
|
|
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, '<span class="cm-comment">$1</span>')
|
|
.replace(str, '<span class="cm-string">$1</span>')
|
|
.replace(kw, '<span class="cm-keyword">$1</span>')
|
|
.replace(num, '<span class="cm-number">$1</span>')
|
|
.replace(fn, '<span class="cm-fn">$1</span>');
|
|
};
|
|
|
|
// ---- 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,
|
|
});
|