Files
2026-10-30 10:11:43 +01:00

884 lines
39 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file app.js
* @brief LPIC-Prüfungssimulator: Sitzungen, Bewertung, Speichern/Import und Darstellung mit reveal.js
* @author Björn Runden <br@runden.de>
* @owner Björn Runden <br@runden.de>
* @assist KI-unterstützt (Claude Opus 5.5), geprüft von Björn Runden <br@runden.de>
* @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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
/** Mini-Markup für Fragetexte: `Code`, **fett**, Zeilenumbruch. Returns: sicheres HTML. */
function fmt(text) {
return String(text ?? '').split(/(`[^`]+`)/).map((part) =>
/^`[^`]+`$/.test(part)
? `<code>${esc(part.slice(1, -1))}</code>`
: esc(part).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>')
).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://<LAN-IP> (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) => `
<div class="exam-group"><span class="exam-level">${esc(lvl)}</span>
${ui.exams.filter((e) => e.level === lvl).map((e, i) => `
<label class="choice"><input type="radio" name="exam" value="${esc(e.id)}"${ui.exams.indexOf(e) === 0 && i === 0 ? ' checked' : ''}>
<span>${esc(e.title)} <small>${esc(e.code || '')} · ${e.bank.length} Fragen</small></span></label>`).join('')}
</div>`).join('');
let resume = '';
if (saved && !validateSession(saved)) {
const done = saved.questions.filter((q) => isAnswered(saved.answers[q.id])).length;
resume = `
<div class="card resume">
<h3>Gespeicherte Sitzung</h3>
<p>${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'))}</p>
<button class="btn primary" data-act="resume">Fortsetzen</button>
<button class="btn" data-act="discard">Verwerfen</button>
</div>`;
}
const html = `
<section class="start">
<h1>LPIC-Prüfungssimulator</h1>
${ui.exams.length ? '' : '<p class="warn-text">Keine Fragenbank gefunden – <code>tools/build_bank.py</code> ausführen oder ein Fragenpaket importieren.</p>'}
${resume}
<form class="card setup" id="setup">
<h3>Neue Sitzung</h3>
<fieldset><legend>Prüfung</legend>${examChoices}</fieldset>
<fieldset><legend>Modus</legend>
<label class="choice"><input type="radio" name="mode" value="learn" checked>
<span>Lernmodus <small>Weiter (→ / Leertaste) zeigt die Auflösung mit Erläuterung</small></span></label>
<label class="choice"><input type="radio" name="mode" value="exam">
<span>Prüfungsmodus <small id="exam-info"></small></span></label>
</fieldset>
<fieldset class="learn-only"><legend>Themen</legend><div id="topic-list"></div></fieldset>
<fieldset class="learn-only"><legend>Ablauf</legend>
<label class="inline">Anzahl Fragen <input type="number" name="count" min="0" value="0" title="0 = alle"> <small>(0 = alle)</small></label>
<label class="inline"><select name="order"><option value="random">zufällige Reihenfolge</option>
<option value="objective">nach Objective sortiert</option></select></label>
</fieldset>
<label class="choice"><input type="checkbox" name="shuffle" checked><span>Antwortoptionen mischen</span></label>
<p><button class="btn primary" type="submit">Starten</button></p>
</form>
<p class="footnote">Objectives laut LPI, Quelle und Version je Prüfung in <code>data/&lt;prüfung&gt;/exam.json</code>.
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.</p>
</section>`;
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) => `
<label class="choice"><input type="checkbox" name="topic" value="${esc(t.id)}" checked>
<span>${esc(t.id)} ${esc(t.title)} <small>${counts[t.id] || 0} Fragen</small></span></label>`).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 = `<input class="fill" type="text" autocomplete="off" autocapitalize="off" spellcheck="false"
value="${esc(a.value || '')}"${locked ? ' disabled' : ''} aria-label="Antwort">`;
} 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 = `<ol class="options">${q.options.map((o, k) => `
<li class="option" data-oid="${o.id}"><label>
<input type="${multi ? 'checkbox' : 'radio'}" name="q_${esc(q.id)}" value="${o.id}"${sel.has(o.id) ? ' checked' : ''}${locked ? ' disabled' : ''}>
<span class="letter">${LETTERS[k]}</span><span class="text">${fmt(o.text)}</span></label></li>`).join('')}
</ol>`;
}
const flag = s.mode === 'exam' && !s.submitted
? `<label class="flag"><input type="checkbox" data-flag${a.flagged ? ' checked' : ''}> zur Überprüfung markieren</label>` : '';
const solution = showSolution ? solutionBlock(q, locked) : '';
const learnBtn = s.mode === 'learn' && !locked ? '<p class="actions"><button class="btn" data-act="next">Auflösen ▸</button></p>' : '';
return `
<section class="question${locked ? ' locked' : ''}" data-qid="${esc(q.id)}" data-index="${i}">
<div class="q-head"><span class="q-no">Frage ${i + 1} von ${s.questions.length}</span>
<span class="q-obj">${esc(q.objective)} ${esc(info.title)}${info.weight ? ` · Gewicht ${info.weight}` : ''}</span>${flag}</div>
<div class="q-text">${fmt(q.question)}</div>
${q.code ? `<pre class="q-code"><code>${esc(q.code)}</code></pre>` : ''}
${hint ? `<p class="q-hint">${hint}</p>` : ''}
${body}
${learnBtn}
${solution}
</section>`;
}
/** 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) ? '<p class="verdict skipped">Nicht beantwortet</p>'
: ok ? '<p class="verdict right">Richtig</p>' : '<p class="verdict wrong">Falsch</p>';
}
let details;
if (q.type === 'fill') {
const alt = q.answers.slice(1);
details = `<p><strong>Musterlösung:</strong> <code>${esc(q.answers[0])}</code>${alt.length
? ` · ebenfalls akzeptiert: ${alt.map((x) => `<code>${esc(x)}</code>`).join(', ')}` : ''}</p>`
+ (a && isAnswered(a) ? `<p>Ihre Antwort: <code>${esc(a.value)}</code></p>` : '');
} else {
details = `<ul class="explain">${q.options.map((o, k) => `
<li class="${o.correct ? 'ok' : 'no'}"><span class="letter">${LETTERS[k]}</span>
<span class="mark">${o.correct ? '✔ richtig' : '✘ falsch'}</span> ${fmt(o.explanation)}</li>`).join('')}</ul>`;
}
const refs = q.references && q.references.length
? `<p class="refs">Nachschlagen: ${q.references.map((r) => `<code>${esc(r)}</code>`).join(' · ')}</p>` : '';
return `
<div class="solution${locked ? '' : ' fragment'}">
<h3>Auflösung</h3>${res}${details}
<div class="explanation">${fmt(q.explanation)}</div>${refs}
</div>`;
}
/** 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) => `<button class="btn small" data-goto="${i}">${i + 1}</button>`).join(' ') : '–';
return `
<section class="final submit">
<h2>Prüfung abgeben</h2>
<p>Beantwortet: ${s.questions.length - open.length} von ${s.questions.length}</p>
<p>Offen: ${jump(open)}</p>
<p>Markiert: ${jump(flagged)}</p>
<p><button class="btn primary" data-act="submit">Prüfung abgeben und auswerten</button></p>
</section>`;
}
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 `<tr><td>${esc(t)}</td><td>${esc(topicTitle(t))}</td><td>${ts.correct}/${ts.total}</td>
<td><span class="bar"><span style="width:${p}%"></span></span> ${p} %</td></tr>`;
}).join('');
const wrong = r.wrong.length
? r.wrong.map((i) => `<button class="btn small" data-goto="${i}">${i + 1}</button>`).join(' ') : '–';
const verdict = r.total === 0 ? ''
: `<p class="verdict ${r.passed ? 'right' : 'wrong'}">${r.passed ? 'Bestanden' : 'Nicht bestanden'} (Richtwert ${s.pass_percent} %)</p>`;
return `
<section class="final result">
<h2>Auswertung${learn ? ' (bisher aufgelöste Fragen)' : ''}</h2>
<p class="big">${r.correct} von ${r.total} richtig · ${r.percent} %</p>
${verdict}
<p class="footnote">LPI bewertet auf einer Skala von 200–800 (bestanden ab 500) und veröffentlicht keine Prozentgrenze.
Der Richtwert ist eine Annahme und in <code>exam.json</code> (<code>pass_percent</code>) einstellbar.</p>
<div class="table-wrap"><table><thead><tr><th>Topic</th><th></th><th>richtig</th><th>Quote</th></tr></thead>
<tbody>${rows}</tbody></table></div>
<p>Falsch/offen (anklicken zum Ansehen): ${wrong}</p>
<p class="actions">
${r.wrong.length ? '<button class="btn primary" data-act="retry">Falsche im Lernmodus wiederholen</button>' : ''}
<button class="btn" data-act="export">Sitzung exportieren</button>
<button class="btn" data-act="menu">Neue Sitzung</button>
</p>
</section>`;
}
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 = '<section></section>';
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);
})();