/** * @file app.js * @brief LPIC-Prüfungssimulator: Sitzungen, Bewertung, Speichern/Import und Darstellung mit reveal.js * @author Björn Runden * @owner Björn Runden * @assist KI-unterstützt (Claude Opus 5.5), geprüft von Björn Runden * @version 0.2.0 (29.10.2026) * * Bewusst klassisches Script (kein ES-Modul, kein fetch): läuft so auch direkt von file://. * Die Fragenbank kommt verschlüsselt aus js/bank.js (window.LPIC_BANK, ChaCha20), weitere Pakete per Import. * Reine Logik liegt in window.LPICSim.core und wird von tests/selftest.html geprüft. */ (function () { 'use strict'; const STORE_SESSION = 'lpic_sim.session'; const STORE_PACKS = 'lpic_sim.packs'; const SESSION_FORMAT = 'lpic-sim-session'; const PACK_FORMAT = 'lpic-sim-questions'; const OPTION_IDS = ['a', 'b', 'c', 'd', 'e']; const LETTERS = 'ABCDE'; const COUNT_WORDS = { 2: 'ZWEI', 3: 'DREI', 4: 'VIER' }; const SAVE_EVERY_S = 15; /* ------------------------------------------------------------------ Speicher */ /** localStorage mit Fehlertoleranz (privater Modus, gesperrter Speicher). */ const store = { get(key) { try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : null; } catch (e) { console.warn('localStorage nicht lesbar:', e); return null; } }, set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (e) { console.warn('localStorage nicht schreibbar:', e); return false; } }, del(key) { try { localStorage.removeItem(key); } catch (e) { console.warn('localStorage:', e); } }, }; /* ------------------------------------------------------------------ Hilfen */ /** Mischt eine Kopie des Arrays (Fisher-Yates). */ function shuffled(arr) { const a = arr.slice(); 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; } /** Vergleicht Objective-IDs numerisch („103.10“ nach „103.9“). */ function cmpObjective(a, b) { const [a1, a2] = a.split('.').map(Number); const [b1, b2] = b.split('.').map(Number); return a1 - b1 || a2 - b2; } function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } /** Mini-Markup für Fragetexte: `Code`, **fett**, Zeilenumbruch. Returns: sicheres HTML. */ function fmt(text) { return String(text ?? '').split(/(`[^`]+`)/).map((part) => /^`[^`]+`$/.test(part) ? `${esc(part.slice(1, -1))}` : esc(part).replace(/\*\*(.+?)\*\*/g, '$1').replace(/\n/g, '
') ).join(''); } function fmtTime(sec) { const s = Math.max(0, Math.floor(sec)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60; const mm = String(m).padStart(2, '0'), ss = String(r).padStart(2, '0'); return h ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; } /** Normalisiert Freitext: Leerraum an den Rändern weg, innen auf ein Leerzeichen. */ function normalize(s, caseSensitive) { const n = String(s ?? '').trim().replace(/\s+/g, ' '); return caseSensitive === false ? n.toLowerCase() : n; } /* ------------------------------------------------------------------ Fragenbank entschlüsseln */ /** * ChaCha20 nach RFC 8439 (Gegenstück zu tools/build_bank.py). Eigene Implementierung, weil * WebCrypto kein ChaCha20 kennt und über http:// (kein Secure Context) ganz fehlt. * @param {Uint8Array} key 32 Byte · @param {Uint8Array} nonce 12 Byte · @param {Uint8Array} data * @returns {Uint8Array} ver-/entschlüsselte Daten */ function chacha20(key, nonce, data, counter = 1) { const kv = new DataView(key.buffer, key.byteOffset, 32); const nv = new DataView(nonce.buffer, nonce.byteOffset, 12); const st = new Uint32Array(16); st.set([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]); for (let i = 0; i < 8; i++) st[4 + i] = kv.getUint32(i * 4, true); for (let i = 0; i < 3; i++) st[13 + i] = nv.getUint32(i * 4, true); const x = new Uint32Array(16); const block = new Uint8Array(64); const bv = new DataView(block.buffer); const out = new Uint8Array(data.length); const qr = (a, b, c, d) => { x[a] += x[b]; x[d] ^= x[a]; x[d] = (x[d] << 16) | (x[d] >>> 16); x[c] += x[d]; x[b] ^= x[c]; x[b] = (x[b] << 12) | (x[b] >>> 20); x[a] += x[b]; x[d] ^= x[a]; x[d] = (x[d] << 8) | (x[d] >>> 24); x[c] += x[d]; x[b] ^= x[c]; x[b] = (x[b] << 7) | (x[b] >>> 25); }; for (let off = 0; off < data.length; off += 64) { st[12] = counter + off / 64; x.set(st); for (let r = 0; r < 10; r++) { qr(0, 4, 8, 12); qr(1, 5, 9, 13); qr(2, 6, 10, 14); qr(3, 7, 11, 15); qr(0, 5, 10, 15); qr(1, 6, 11, 12); qr(2, 7, 8, 13); qr(3, 4, 9, 14); } for (let i = 0; i < 16; i++) bv.setUint32(i * 4, (x[i] + st[i]) >>> 0, true); const n = Math.min(64, data.length - off); for (let i = 0; i < n; i++) out[off + i] = data[off + i] ^ block[i]; } return out; } const hexBytes = (h) => new Uint8Array(String(h).match(/../g).map((b) => parseInt(b, 16))); /** Entschlüsselt window.LPIC_BANK (Felder build, rev, payload). Returns: {exams} oder wirft Error. */ function decodeBank(raw) { if (!raw) return { exams: [] }; if (!raw.payload) return raw; // unverschlüsselte Bank (ältere Builds) const bin = atob(raw.payload); const data = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) data[i] = bin.charCodeAt(i); const plain = chacha20(hexBytes(raw.build), hexBytes(raw.rev), data); try { return JSON.parse(new TextDecoder().decode(plain)); } catch (e) { throw new Error('Fragenbank nicht lesbar (bank.js beschädigt oder unvollständig)'); } } /* ------------------------------------------------------------------ Kernlogik */ /** Prüft eine Frage gegen die Erstellungsregeln. Returns: Fehlertext oder null. */ function validateQuestion(q, objectives) { if (!q || typeof q !== 'object') return 'keine Frage'; if (!/^\d{3}\.\d+-\d{3}$/.test(q.id || '')) return `ungültige id ${q.id}`; if (!objectives.has(q.objective)) return `${q.id}: unbekanntes Objective ${q.objective}`; if (!String(q.question || '').trim() || !String(q.explanation || '').trim()) return `${q.id}: question/explanation fehlt`; if (q.type === 'fill') { return Array.isArray(q.answers) && q.answers.length ? null : `${q.id}: answers fehlt`; } if (q.type !== 'single' && q.type !== 'multi') return `${q.id}: unbekannter type ${q.type}`; const opts = q.options || []; if (opts.map((o) => o.id).join() !== OPTION_IDS.join()) return `${q.id}: genau 5 Optionen a–e nötig`; if (opts.some((o) => !String(o.text || '').trim() || !String(o.explanation || '').trim())) return `${q.id}: Option ohne Text/Erläuterung`; const nOk = opts.filter((o) => o.correct === true).length; if (q.type === 'single' && nOk !== 1) return `${q.id}: single braucht genau 1 richtige Option`; if (q.type === 'multi' && (nOk < 2 || nOk > 4)) return `${q.id}: multi braucht 2–4 richtige Optionen`; return null; } /** Alle Objective-IDs einer Prüfung. */ function objectiveIds(exam) { return new Set(exam.topics.flatMap((t) => t.objectives.map((o) => o.id))); } /** Objective-Metadaten (Titel, Gewicht, Topic) zu einer ID. */ function objectiveInfo(exam, oid) { for (const t of exam.topics) { const o = t.objectives.find((x) => x.id === oid); if (o) return { ...o, topic: t.id, topicTitle: t.title }; } return { id: oid, title: '', weight: 0, topic: oid.split('.')[0], topicTitle: '' }; } /** * Fügt ein Fragenpaket in die Bank ein (gleiche id ersetzt). * Ein Paket mit "definition" (Inhalt einer exam.json) legt eine neue Prüfung an. * Returns: {added, replaced, errors} */ function mergePack(bankExams, pack) { const res = { added: 0, replaced: 0, errors: [] }; if (!pack || pack.format !== PACK_FORMAT) { res.errors.push(`format muss '${PACK_FORMAT}' sein`); return res; } let exam = bankExams.find((e) => e.id === pack.exam); if (!exam && pack.definition && pack.definition.exam && pack.definition.exam.id === pack.exam) { exam = { ...pack.definition.exam, topics: pack.definition.topics || [], bank: [] }; bankExams.push(exam); } if (!exam) { res.errors.push(`Prüfung '${pack.exam}' unbekannt (Paket ohne "definition")`); return res; } const objectives = objectiveIds(exam); for (const q of pack.questions || []) { if (q && q.review && q.review.status === 'rejected') continue; // von Prüfern abgelehnt const err = validateQuestion(q, objectives); if (err) { res.errors.push(err); continue; } const idx = exam.bank.findIndex((x) => x.id === q.id); if (idx >= 0) { exam.bank[idx] = q; res.replaced++; } else { exam.bank.push(q); res.added++; } } exam.bank.sort((a, b) => cmpObjective(a.objective, b.objective) || a.id.localeCompare(b.id)); return res; } /** * Wählt n Fragen gewichtet nach Objective (wie die echte Prüfung: Gewicht = Fragenzahl). * Fehlende Fragen eines Objectives werden zufällig aus dem Rest aufgefüllt. */ function pickWeighted(exam, pool, n) { const byObj = new Map(); for (const q of shuffled(pool)) { if (!byObj.has(q.objective)) byObj.set(q.objective, []); byObj.get(q.objective).push(q); } const objs = [...byObj.keys()].map((id) => ({ id, w: objectiveInfo(exam, id).weight || 1 })); const total = objs.reduce((s, o) => s + o.w, 0) || 1; const target = Math.min(n, pool.length); for (const o of objs) { o.exact = (target * o.w) / total; o.quota = Math.min(Math.floor(o.exact), byObj.get(o.id).length); } let left = target - objs.reduce((s, o) => s + o.quota, 0); for (const o of objs.slice().sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)))) { if (left <= 0) break; if (o.quota < byObj.get(o.id).length) { o.quota++; left--; } } const picked = objs.flatMap((o) => byObj.get(o.id).slice(0, o.quota)); if (picked.length < target) { const ids = new Set(picked.map((q) => q.id)); picked.push(...shuffled(pool.filter((q) => !ids.has(q.id))).slice(0, target - picked.length)); } return picked; } /** * Legt eine neue Sitzung an. Die Fragen werden als Kopie in die Sitzung übernommen, * damit Export/Import unabhängig vom späteren Stand der Bank funktioniert. * @param {object} exam Prüfung aus der Bank * @param {object} o {mode:'learn'|'exam', topics?:string[], count?:number, order?:'random'|'objective', shuffleOptions?:bool, questionIds?:string[]} */ function createSession(exam, o) { const mode = o.mode === 'exam' ? 'exam' : 'learn'; let pool = exam.bank; if (o.questionIds) pool = pool.filter((q) => o.questionIds.includes(q.id)); else if (mode === 'learn' && o.topics && o.topics.length) pool = pool.filter((q) => o.topics.includes(q.objective.split('.')[0])); let list; if (mode === 'exam') { list = shuffled(pickWeighted(exam, pool, exam.questions || 60)); } else { list = o.order === 'objective' ? pool.slice().sort((a, b) => cmpObjective(a.objective, b.objective) || a.id.localeCompare(b.id)) : shuffled(pool); if (o.count > 0) list = list.slice(0, o.count); } const questions = list.map((q) => { const c = JSON.parse(JSON.stringify(q)); if (c.options && o.shuffleOptions !== false) c.options = shuffled(c.options); return c; }); const now = new Date().toISOString(); return { format: SESSION_FORMAT, version: 1, id: Date.now().toString(36), exam_id: exam.id, exam_title: exam.title, exam_code: exam.code, topics: exam.topics, mode, created: now, updated: now, elapsed_s: 0, time_limit_s: mode === 'exam' ? (exam.duration_min || 90) * 60 : 0, pass_percent: exam.pass_percent || 65, current: 0, submitted: false, questions, answers: {}, }; } /** Anzahl richtiger Optionen einer Choice-Frage. */ function correctCount(q) { return (q.options || []).filter((o) => o.correct).length; } /** Bewertet eine Antwort (alles oder nichts, wie LPI). Returns: true/false. */ function isCorrect(q, value) { if (q.type === 'fill') { const v = normalize(value, q.case_sensitive); return v !== '' && q.answers.some((a) => normalize(a, q.case_sensitive) === v); } const sel = new Set(Array.isArray(value) ? value : value ? [value] : []); const ok = q.options.filter((o) => o.correct).map((o) => o.id); return sel.size === ok.length && ok.every((id) => sel.has(id)); } /** Hat die Frage eine (nicht leere) Antwort? */ function isAnswered(a) { if (!a) return false; return Array.isArray(a.value) ? a.value.length > 0 : normalize(a.value) !== ''; } /** Prüft eine importierte Sitzung grob. Returns: Fehlertext oder null. */ function validateSession(s) { if (!s || s.format !== SESSION_FORMAT) return `format muss '${SESSION_FORMAT}' sein`; if (s.version !== 1) return `Sitzungsversion ${s.version} wird nicht unterstützt`; if (!Array.isArray(s.questions) || !s.questions.length) return 'Sitzung ohne Fragen'; if (typeof s.answers !== 'object' || s.answers === null) return 'Sitzung ohne answers'; return null; } /** * Auswertung einer Sitzung, gesamt und je Topic. * @param {boolean} onlyRevealed im Lernmodus nur bereits aufgelöste Fragen zählen */ function score(session, onlyRevealed) { const res = { total: 0, correct: 0, answered: 0, wrong: [], topics: {} }; session.questions.forEach((q, i) => { const a = session.answers[q.id]; if (onlyRevealed && !(a && a.revealed)) return; const t = q.objective.split('.')[0]; const ts = res.topics[t] || (res.topics[t] = { total: 0, correct: 0 }); const ok = !!a && isCorrect(q, a.value); res.total++; ts.total++; if (isAnswered(a)) res.answered++; if (ok) { res.correct++; ts.correct++; } else res.wrong.push(i); }); res.percent = res.total ? Math.round((res.correct / res.total) * 100) : 0; res.passed = res.percent >= session.pass_percent; return res; } const core = { chacha20, decodeBank, validateQuestion, mergePack, pickWeighted, createSession, isCorrect, isAnswered, validateSession, score, fmt, normalize, shuffled }; /* ------------------------------------------------------------------ Oberfläche */ const ui = { exams: [], // Bank inkl. importierter Pakete session: null, // laufende Sitzung oder null (Startseite) tickCount: 0, }; const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); /** Lädt die Bank aus bank.js und gespeicherte Pakete aus localStorage. */ function loadBank() { try { ui.exams = decodeBank(window.LPIC_BANK).exams || []; } catch (e) { ui.exams = []; console.error(e); setTimeout(() => toast(e.message, 'error'), 0); } for (const pack of store.get(STORE_PACKS) || []) mergePack(ui.exams, pack); ui.exams.sort((a, b) => String(a.level).localeCompare(String(b.level)) || (a.order || 0) - (b.order || 0)); } function save() { if (!ui.session) return; ui.session.updated = new Date().toISOString(); if (!store.set(STORE_SESSION, ui.session)) toast('Speichern im Browser nicht möglich – bitte regelmäßig exportieren.', 'warn'); } function toast(msg, kind = 'info') { const box = $('#toast'); const el = document.createElement('div'); el.className = `toast ${kind}`; el.textContent = msg; box.appendChild(el); setTimeout(() => el.remove(), kind === 'error' ? 9000 : 4500); } /** Bietet Text als Datei zum Speichern an. */ function download(name, text) { const url = URL.createObjectURL(new Blob([text], { type: 'application/json' })); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } /** Setzt die Folien neu und synchronisiert reveal.js. */ function setSlides(html, index) { $('.reveal .slides').innerHTML = html; Reveal.sync(); Reveal.slide(index || 0, 0); updateToolbar(); } /* ----------------------------- Startseite */ function renderStart() { ui.session = null; const saved = store.get(STORE_SESSION); const levels = [...new Set(ui.exams.map((e) => e.level))]; const examChoices = levels.map((lvl) => `
${esc(lvl)} ${ui.exams.filter((e) => e.level === lvl).map((e, i) => ` `).join('')}
`).join(''); let resume = ''; if (saved && !validateSession(saved)) { const done = saved.questions.filter((q) => isAnswered(saved.answers[q.id])).length; resume = `

Gespeicherte Sitzung

${esc(saved.exam_title)} · ${saved.mode === 'exam' ? 'Prüfungsmodus' : 'Lernmodus'}${saved.submitted ? ' · abgegeben' : ''} · ${done}/${saved.questions.length} beantwortet · Stand ${esc(new Date(saved.updated).toLocaleString('de-DE'))}

`; } const html = `

LPIC-Prüfungssimulator

${ui.exams.length ? '' : '

Keine Fragenbank gefunden – tools/build_bank.py ausführen oder ein Fragenpaket importieren.

'} ${resume}

Neue Sitzung

Prüfung${examChoices}
Modus
Themen
Ablauf

Objectives laut LPI, Quelle und Version je Prüfung in data/<prüfung>/exam.json. Stand der Fragenbank: ${esc(window.LPIC_BANK ? window.LPIC_BANK.generated : '–')}. Tasten: → / Leertaste weiter · ← zurück · 1–5 Antwort wählen · Enter im Textfeld = weiter.

`; setSlides(html, 0); updateStartForm(); } /** Aktualisiert Themenliste und Hinweise je gewählter Prüfung/Modus. */ function updateStartForm() { const form = $('#setup'); if (!form) return; const exam = ui.exams.find((e) => e.id === (form.exam && form.exam.value)); const learn = form.mode.value === 'learn'; $$('.learn-only', form).forEach((el) => { el.hidden = !learn; }); if (!exam) return; $('#exam-info').textContent = `${exam.questions} Fragen, ${exam.duration_min} Minuten, gewichtet nach Objectives, Auflösung erst nach Abgabe`; const counts = {}; exam.bank.forEach((q) => { const t = q.objective.split('.')[0]; counts[t] = (counts[t] || 0) + 1; }); const list = $('#topic-list'); if (list.dataset.exam !== exam.id) { list.dataset.exam = exam.id; list.innerHTML = exam.topics.map((t) => ` `).join(''); } } function startFromForm(form) { const exam = ui.exams.find((e) => e.id === form.exam.value); if (!exam) { toast('Bitte eine Prüfung wählen.', 'warn'); return; } const topics = $$('input[name=topic]:checked', form).map((i) => i.value); const s = createSession(exam, { mode: form.mode.value, topics, count: parseInt(form.count.value, 10) || 0, order: form.order.value, shuffleOptions: form.shuffle.checked, }); if (!s.questions.length) { toast('Keine Fragen für diese Auswahl.', 'warn'); return; } startSession(s, 0); } function startSession(session, index) { ui.session = session; save(); renderSession(index); } /* ----------------------------- Fragen-Folien */ function renderSession(index) { const s = ui.session; const slides = s.questions.map((q, i) => questionSlide(q, i)).join(''); const last = s.mode === 'exam' && !s.submitted ? submitSlide() : resultSlide(); setSlides(slides + last, Math.min(index ?? s.current ?? 0, s.questions.length)); } /** Ist die Frage gesperrt (aufgelöst bzw. Prüfung abgegeben)? */ function isLocked(q) { const a = ui.session.answers[q.id]; return ui.session.submitted || !!(a && a.revealed); } function questionSlide(q, i) { const s = ui.session; const a = s.answers[q.id] || {}; const locked = isLocked(q); const info = objectiveInfo({ topics: s.topics }, q.objective); const showSolution = s.mode === 'learn' || s.submitted; const nOk = correctCount(q); let hint = ''; let body; if (q.type === 'fill') { hint = `Antwort eintippen${q.case_sensitive === false ? '' : ' – Groß-/Kleinschreibung beachten'}.`; body = ``; } else { const multi = q.type === 'multi'; if (multi) hint = `Wählen Sie ${COUNT_WORDS[nOk] || nOk}.`; const sel = new Set(Array.isArray(a.value) ? a.value : []); body = `
    ${q.options.map((o, k) => `
  1. `).join('')}
`; } const flag = s.mode === 'exam' && !s.submitted ? `` : ''; const solution = showSolution ? solutionBlock(q, locked) : ''; const learnBtn = s.mode === 'learn' && !locked ? '

' : ''; return `
Frage ${i + 1} von ${s.questions.length} ${esc(q.objective)} ${esc(info.title)}${info.weight ? ` · Gewicht ${info.weight}` : ''}${flag}
${fmt(q.question)}
${q.code ? `
${esc(q.code)}
` : ''} ${hint ? `

${hint}

` : ''} ${body} ${learnBtn} ${solution}
`; } /** Auflösung: Ergebnis, Erläuterung je Option, Gesamterläuterung. Ungesperrt als reveal-Fragment. */ function solutionBlock(q, locked) { const a = ui.session.answers[q.id]; let res = ''; if (locked) { const ok = !!a && isCorrect(q, a.value); res = !isAnswered(a) ? '

Nicht beantwortet

' : ok ? '

Richtig

' : '

Falsch

'; } let details; if (q.type === 'fill') { const alt = q.answers.slice(1); details = `

Musterlösung: ${esc(q.answers[0])}${alt.length ? ` · ebenfalls akzeptiert: ${alt.map((x) => `${esc(x)}`).join(', ')}` : ''}

` + (a && isAnswered(a) ? `

Ihre Antwort: ${esc(a.value)}

` : ''); } else { details = `
    ${q.options.map((o, k) => `
  • ${LETTERS[k]} ${o.correct ? '✔ richtig' : '✘ falsch'} ${fmt(o.explanation)}
  • `).join('')}
`; } const refs = q.references && q.references.length ? `

Nachschlagen: ${q.references.map((r) => `${esc(r)}`).join(' · ')}

` : ''; return `

Auflösung

${res}${details}
${fmt(q.explanation)}
${refs}
`; } /** Markiert Optionen nach der Auflösung (richtig/falsch gewählt). */ function markOptions(section, q) { const a = ui.session.answers[q.id]; const sel = new Set(a && Array.isArray(a.value) ? a.value : []); $$('.option', section).forEach((li) => { const o = q.options.find((x) => x.id === li.dataset.oid); li.classList.toggle('is-correct', o.correct); li.classList.toggle('is-wrong', !o.correct && sel.has(o.id)); li.classList.toggle('is-missed', o.correct && !sel.has(o.id)); }); const fill = $('.fill', section); if (fill) fill.classList.add(a && isCorrect(q, a.value) ? 'is-correct' : 'is-wrong'); } /** Lernmodus: Frage auflösen, sperren und Auflösung dauerhaft sichtbar machen. */ function revealQuestion(section) { const s = ui.session; const q = s.questions[Number(section.dataset.index)]; const a = s.answers[q.id] || (s.answers[q.id] = { value: q.type === 'fill' ? '' : [] }); if (a.revealed) return; a.revealed = true; save(); const fresh = document.createElement('div'); fresh.innerHTML = solutionBlock(q, true); $('.solution', section).replaceWith(fresh.firstElementChild); $$('input', section).forEach((i) => { i.disabled = true; }); const btn = $('.actions', section); if (btn) btn.remove(); section.classList.add('locked'); markOptions(section, q); Reveal.syncFragments(); $('.solution', section).scrollIntoView({ behavior: 'smooth', block: 'nearest' }); updateToolbar(); } /* ----------------------------- Abgabe & Ergebnis */ function submitSlide() { const s = ui.session; const open = [], flagged = []; s.questions.forEach((q, i) => { const a = s.answers[q.id]; if (!isAnswered(a)) open.push(i); if (a && a.flagged) flagged.push(i); }); const jump = (list) => list.length ? list.map((i) => ``).join(' ') : '–'; return `

Prüfung abgeben

Beantwortet: ${s.questions.length - open.length} von ${s.questions.length}

Offen: ${jump(open)}

Markiert: ${jump(flagged)}

`; } function resultSlide() { const s = ui.session; const learn = s.mode === 'learn'; const r = score(s, learn); const topicTitle = (id) => (s.topics.find((t) => t.id === id) || {}).title || ''; const rows = Object.keys(r.topics).sort().map((t) => { const ts = r.topics[t]; const p = Math.round((ts.correct / ts.total) * 100); return `${esc(t)}${esc(topicTitle(t))}${ts.correct}/${ts.total} ${p} %`; }).join(''); const wrong = r.wrong.length ? r.wrong.map((i) => ``).join(' ') : '–'; const verdict = r.total === 0 ? '' : `

${r.passed ? 'Bestanden' : 'Nicht bestanden'} (Richtwert ${s.pass_percent} %)

`; return `

Auswertung${learn ? ' (bisher aufgelöste Fragen)' : ''}

${r.correct} von ${r.total} richtig · ${r.percent} %

${verdict}

LPI bewertet auf einer Skala von 200–800 (bestanden ab 500) und veröffentlicht keine Prozentgrenze. Der Richtwert ist eine Annahme und in exam.json (pass_percent) einstellbar.

${rows}
TopicrichtigQuote

Falsch/offen (anklicken zum Ansehen): ${wrong}

${r.wrong.length ? '' : ''}

`; } function submitExam(auto) { const s = ui.session; s.submitted = true; save(); renderSession(s.questions.length); toast(auto ? 'Zeit abgelaufen – Prüfung automatisch abgegeben.' : 'Prüfung abgegeben.', auto ? 'warn' : 'info'); } /** Neue Lernsitzung mit den falsch beantworteten Fragen der aktuellen Sitzung. */ function retryWrong() { const s = ui.session; const r = score(s, s.mode === 'learn'); const qs = r.wrong.map((i) => s.questions[i]); const exam = { ...(ui.exams.find((e) => e.id === s.exam_id) || {}), id: s.exam_id, title: s.exam_title, code: s.exam_code, topics: s.topics, pass_percent: s.pass_percent, bank: qs }; startSession(createSession(exam, { mode: 'learn', order: 'random', shuffleOptions: true }), 0); } /* ----------------------------- Werkzeugleiste, Timer */ /** Überträgt die tatsächliche Höhe der (ggf. umbrochenen) Werkzeugleiste nach CSS --tb-h. */ function syncToolbarHeight() { document.documentElement.style.setProperty('--tb-h', `${$('.toolbar').offsetHeight}px`); } function updateToolbar() { const s = ui.session; $('#tb-title').textContent = s ? `${s.exam_title} · ${s.mode === 'exam' ? 'Prüfung' : 'Lernen'}` : 'LPIC-Prüfungssimulator'; $('#tb-export').disabled = !s; if (!s) { $('#tb-progress').textContent = ''; $('#tb-timer').textContent = ''; return; } const done = s.questions.filter((q) => isAnswered(s.answers[q.id])).length; $('#tb-progress').textContent = `${done}/${s.questions.length} beantwortet`; const timer = $('#tb-timer'); if (s.mode === 'exam' && !s.submitted) { const rest = s.time_limit_s - s.elapsed_s; timer.textContent = `⏱ ${fmtTime(rest)}`; timer.classList.toggle('low', rest < 600); } else { timer.textContent = `⏱ ${fmtTime(s.elapsed_s)}`; timer.classList.remove('low'); } } function tick() { const s = ui.session; if (!s || s.submitted) return; s.elapsed_s++; if (++ui.tickCount % SAVE_EVERY_S === 0) save(); if (s.mode === 'exam' && s.elapsed_s >= s.time_limit_s) { submitExam(true); return; } updateToolbar(); } /* ----------------------------- Export / Import */ function exportSession() { const s = ui.session; if (!s) return; save(); const d = new Date(); const pad = (n) => String(n).padStart(2, '0'); const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`; download(`lpic_sim_${s.exam_id}_${s.mode}_${stamp}.json`, JSON.stringify(s, null, 1)); } /** Import: erkennt Sitzung (fortsetzen) oder Fragenpaket (in Bank übernehmen). */ function importFile(file) { const reader = new FileReader(); reader.onload = () => { let doc; try { doc = JSON.parse(reader.result); } catch (e) { toast(`${file.name}: kein gültiges JSON (${e.message})`, 'error'); return; } if (doc.format === SESSION_FORMAT) { const err = validateSession(doc); if (err) { toast(`${file.name}: ${err}`, 'error'); return; } startSession(doc, doc.current || 0); toast(`Sitzung „${doc.exam_title}“ geladen – weiter bei Frage ${Math.min((doc.current || 0) + 1, doc.questions.length)}.`); } else if (doc.format === PACK_FORMAT) { const res = mergePack(ui.exams, doc); if (res.added + res.replaced > 0) { const packs = (store.get(STORE_PACKS) || []).filter((p) => p.name !== file.name); packs.push({ ...doc, name: file.name }); store.set(STORE_PACKS, packs); } res.errors.slice(0, 5).forEach((e) => toast(e, 'error')); toast(`${file.name}: ${res.added} neu, ${res.replaced} ersetzt${res.errors.length ? `, ${res.errors.length} abgelehnt` : ''}.`, res.errors.length ? 'warn' : 'info'); if (!ui.session) renderStart(); } else { toast(`${file.name}: unbekanntes Format (erwartet ${SESSION_FORMAT} oder ${PACK_FORMAT})`, 'error'); } }; reader.readAsText(file); } /* ----------------------------- Ereignisse */ function onChange(ev) { const t = ev.target; if (t.closest('#setup')) { updateStartForm(); return; } const section = t.closest('section.question'); if (!section || !ui.session) return; const s = ui.session; const q = s.questions[Number(section.dataset.index)]; const a = s.answers[q.id] || (s.answers[q.id] = { value: q.type === 'fill' ? '' : [] }); if (t.hasAttribute('data-flag')) { a.flagged = t.checked; save(); return; } if (isLocked(q)) return; if (q.type === 'single') a.value = [t.value]; else if (q.type === 'multi') { let sel = $$('.options input:checked', section).map((i) => i.value); const max = correctCount(q); if (sel.length > max) { t.checked = false; sel = sel.filter((v) => v !== t.value); toast(`Höchstens ${max} Antworten wählbar.`, 'warn'); } a.value = sel; } else a.value = t.value; save(); updateToolbar(); } function onClick(ev) { const btn = ev.target.closest('[data-act],[data-goto]'); if (!btn) return; if (btn.dataset.goto !== undefined) { Reveal.slide(Number(btn.dataset.goto)); return; } switch (btn.dataset.act) { case 'next': Reveal.next(); break; case 'prev': Reveal.prev(); break; case 'resume': { const s = store.get(STORE_SESSION); if (s) { ui.session = s; renderSession(s.current); } break; } case 'discard': if (window.confirm('Gespeicherte Sitzung verwerfen? (Vorher exportieren, falls sie noch gebraucht wird.)')) { store.del(STORE_SESSION); renderStart(); } break; case 'submit': { const open = ui.session.questions.filter((q) => !isAnswered(ui.session.answers[q.id])).length; if (!open || window.confirm(`${open} Fragen sind offen. Trotzdem abgeben?`)) submitExam(false); break; } case 'retry': retryWrong(); break; case 'export': exportSession(); break; case 'import': $('#import-file').click(); break; case 'menu': save(); renderStart(); break; default: break; } } function onKey(ev) { const t = ev.target; if (t.matches && t.matches('input.fill')) { if (ev.key === 'Enter') { ev.preventDefault(); t.blur(); Reveal.next(); } return; } if (t.matches && t.matches('input[type=text],input[type=number],select,textarea')) return; if (ev.ctrlKey || ev.altKey || ev.metaKey) return; const n = Number(ev.key); if (n >= 1 && n <= 5) { const input = $$('.present .options input')[n - 1]; if (input && !input.disabled) { input.click(); ev.preventDefault(); } } } function onSlideChanged(ev) { const s = ui.session; if (!s) return; s.current = ev.indexh; const sec = ev.currentSlide; if (sec.classList.contains('result')) { // Lernmodus: Auswertung bei jedem Aufruf neu berechnen const fresh = document.createElement('div'); fresh.innerHTML = resultSlide(); sec.innerHTML = fresh.firstElementChild.innerHTML; } else if (sec.classList.contains('submit')) { const fresh = document.createElement('div'); fresh.innerHTML = submitSlide(); sec.innerHTML = fresh.firstElementChild.innerHTML; } else if (sec.classList.contains('question')) { const q = s.questions[Number(sec.dataset.index)]; if (isLocked(q) && q.options) markOptions(sec, q); sec.scrollTop = 0; } save(); updateToolbar(); } function onFragmentShown(ev) { const frag = ev.fragment; if (frag && frag.classList.contains('solution')) revealQuestion(frag.closest('section.question')); } function init() { loadBank(); // Erst eine Platzhalter-Folie, dann reveal.js starten, dann die echte Startseite setzen $('.reveal .slides').innerHTML = '
'; Reveal.initialize({ hash: false, history: false, controls: true, controlsTutorial: false, progress: true, center: false, disableLayout: true, scrollActivationWidth: null, overview: false, touch: false, pause: false, transition: 'slide', transitionSpeed: 'fast', keyboard: { 66: null, 190: null, 79: null }, // B, Punkt, O: Pause/Übersicht aus }).then(() => { Reveal.on('slidechanged', onSlideChanged); Reveal.on('fragmentshown', onFragmentShown); const slides = $('.reveal .slides'); slides.addEventListener('change', onChange); slides.addEventListener('input', (ev) => { if (ev.target.matches('input.fill')) onChange(ev); }); slides.addEventListener('submit', (ev) => { ev.preventDefault(); startFromForm(ev.target); }); document.addEventListener('click', onClick); document.addEventListener('keydown', onKey); $('#import-file').addEventListener('change', (ev) => { const f = ev.target.files && ev.target.files[0]; if (f) importFile(f); ev.target.value = ''; }); $('#tb-export').addEventListener('click', exportSession); window.addEventListener('beforeunload', save); new ResizeObserver(syncToolbarHeight).observe($('.toolbar')); setInterval(tick, 1000); renderStart(); }); } window.LPICSim = { core, ui }; if (window.Reveal && !window.LPICSIM_NO_UI) document.addEventListener('DOMContentLoaded', init); })();