/** * Normalize text for freitext comparison. * "Villingen-Schwenningen" == "villingen schwenningen" == "villingenschwenningen" */ function normalizeText(text) { if (!text) return ''; return text .toLowerCase() .replace(/[äÄ]/g, 'ae') .replace(/[öÖ]/g, 'oe') .replace(/[üÜ]/g, 'ue') .replace(/ß/g, 'ss') .replace(/[^a-z0-9]/g, '') .trim(); } /** * Extract the string value from a Baserow Single Select field. * Baserow returns { id: 6, value: "MC", color: "..." } for Single Select. */ function extractFieldValue(field, fallback) { if (!field) return fallback || ''; if (typeof field === 'string') return field; if (typeof field === 'object' && field.value !== undefined) return field.value; return String(field); } function getTyp(question) { return extractFieldValue(question['Typ'], 'MC').toLowerCase(); } function checkFreitext(userAnswer, correctAnswer) { return normalizeText(userAnswer) === normalizeText(correctAnswer); } function parseCorrectAnswers(question) { const raw = (question['Richtige Antwort'] || '').trim(); const typ = getTyp(question); if (typ === 'freitext') return [raw]; return raw .split(',') .map((s) => s.trim().toUpperCase()) .filter(Boolean); } function countOptions(question) { let n = 0; if (question['Antwort A']) n++; if (question['Antwort B']) n++; if (question['Antwort C']) n++; if (question['Antwort D']) n++; return n; } function shuffleArray(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } /** * Calculate score for an answer. */ function calculateScore(question, selected, timeRemaining, totalTime, scoringMode) { if (!selected || selected.length === 0) return { points: 0, correct: false }; let basis; if (scoringMode === 'time' && totalTime > 0) { const ratio = Math.max(0, Math.min(1, timeRemaining / totalTime)); basis = 500 + Math.round(ratio * 500); } else { basis = 1000; } const typ = getTyp(question); const correctAnswers = parseCorrectAnswers(question); if (typ === 'freitext') { const ok = checkFreitext(selected[0], correctAnswers[0]); return { points: ok ? basis : 0, correct: ok }; } const correctSet = new Set(correctAnswers); const totalCorrect = correctSet.size; const totalIncorrect = countOptions(question) - totalCorrect; let rightPicked = 0; let wrongPicked = 0; for (const s of selected) { if (correctSet.has(s.toUpperCase())) rightPicked++; else wrongPicked++; } let points = (rightPicked / Math.max(totalCorrect, 1)) * basis; if (totalIncorrect > 0) { points -= (wrongPicked / totalIncorrect) * (basis / 2); } points = Math.max(0, Math.round(points)); const fullyCorrect = rightPicked === totalCorrect && wrongPicked === 0; return { points, correct: fullyCorrect }; } /** * Strip the correct answer and shuffle options before sending to participants. */ function sanitizeQuestion(question) { const typ = getTyp(question); const sanitized = { id: question.id, frage: question['Frage'] || '', typ: extractFieldValue(question['Typ'], 'MC'), bild: null, antworten: [], kategorie: extractFieldValue(question['Kategorie'], ''), }; const bild = question['Bild']; if (bild && Array.isArray(bild) && bild.length > 0) { sanitized.bild = bild[0].url; } if (typ !== 'freitext') { const keys = ['A', 'B', 'C', 'D']; for (const k of keys) { const val = question[`Antwort ${k}`]; if (val) sanitized.antworten.push({ key: k, text: val }); } sanitized.antworten = shuffleArray(sanitized.antworten); } return sanitized; } module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText, shuffleArray, extractFieldValue };