Files
brainwave/docs/design_handoff_brainwave/app/utils.js
T
LaurensBartelds be69db04a5 Add SVG icons and TypeScript configuration files
- 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.
2026-04-21 22:19:06 +02:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// 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 = /(&quot;[^&]*?&quot;|&#39;[^&]*?&#39;|`[^`]*?`)/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,
});