rein damit

This commit is contained in:
Björn Runden committed 2026-10-30 10:11:43 +01:00
1 parent 7f6b8ebe07
commit 07dbc01c92
38 files changed
+5632 -5

No files matched your search

+883
View File
@@ -0,0 +1,883 @@
/**
* @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);
})();