diff --git a/assets/exam-simulator.css b/assets/exam-simulator.css index a408aae..e29bf7a 100644 --- a/assets/exam-simulator.css +++ b/assets/exam-simulator.css @@ -64,6 +64,14 @@ border-right-width: 2px; } +.exam-answer-sheet-homework .answer-table tr > :first-child { + width: 28%; +} + +.exam-answer-sheet-homework .answer-table { + font-size: 0.72rem; +} + .answer-number, .answer-mark { width: 100%; diff --git a/assets/exam-simulator.js b/assets/exam-simulator.js index e9ee19c..945d732 100644 --- a/assets/exam-simulator.js +++ b/assets/exam-simulator.js @@ -11,7 +11,8 @@ V: { label: "Kenntnisse von Vorschriften", minutes: 45 }, N: { label: "Technische Kenntnisse Klasse N", minutes: 45 }, E: { label: "Technische Kenntnisse Klasse E", minutes: 45 }, - A: { label: "Technische Kenntnisse Klasse A", minutes: 60 } + A: { label: "Technische Kenntnisse Klasse A", minutes: 60 }, + H: { label: "Hausaufgabe", minutes: null } }; var EXAM_CHOICES = [ @@ -28,49 +29,50 @@ { id: "part-A", group: "single", label: "A", detail: "Technik Klasse A", parts: ["A"], color: "#3BB583" } ]; - function normalizeCatalog(rawCatalog, metadata, questionIndex) { - var questions = {}; - var partForSection = { 1: "B", 2: "V" }; - var partForClass = { "1": "N", "2": "E", "3": "A" }; + function queryValue(name) { + return new URLSearchParams(window.location.search).get(name); + } - function pictureId(value) { - return value ? String(value) : ""; - } - - function walk(section, sectionIndex, category) { - (section.questions || []).forEach(function (question) { - var part = sectionIndex === 0 - ? partForClass[String(question.class)] - : partForSection[sectionIndex]; - if (!part) return; - - var questionMetadata = metadata[question.number] || {}; - questions[question.number] = { - id: question.number, - part: part, - category: category.slice(), - question: question.question, - layout: questionMetadata.layout || "default", - picture: pictureId(questionMetadata.picture_question), - answers: ["a", "b", "c", "d"].map(function (letter) { - return { - text: question["answer_" + letter] || "", - picture: pictureId(questionMetadata["picture_" + letter]) - }; - }), - hasSolution: Boolean(questionIndex[question.number] && questionIndex[question.number].has_solution) - }; - }); - - (section.sections || []).forEach(function (child, childIndex) { - walk(child, sectionIndex, category.concat(childIndex)); - }); - } - - (rawCatalog.sections || []).forEach(function (section, sectionIndex) { - walk(section, sectionIndex, []); + function parseHomeworkQuestionIds() { + var value = queryValue("homework"); + if (!value) return []; + var seen = new Set(); + return value.toUpperCase().split(/[\s+,]+/).filter(function (questionId) { + if (!/^[A-Z]{2}\d{3}$/.test(questionId) || seen.has(questionId)) return false; + seen.add(questionId); + return true; }); - return questions; + } + + function parseSharedResult() { + var fields = (queryValue("result") || "").split("."); + if (fields[0] !== "1") return null; + if (fields[1] === "H") { + var homeworkScore = Number(fields[2]); + var homeworkTotal = Number(fields[3]); + if (fields.length !== 4 || !Number.isInteger(homeworkScore) || !Number.isInteger(homeworkTotal) || + homeworkTotal < 1 || homeworkScore < 0 || homeworkScore > homeworkTotal) return null; + return { kind: "homework", score: homeworkScore, total: homeworkTotal }; + } + var choice = EXAM_CHOICES.find(function (item) { return item.id === fields[1]; }); + if (!choice || fields.length !== choice.parts.length + 2) return null; + var scores = fields.slice(2).map(Number); + if (scores.some(function (score) { return !Number.isInteger(score) || score < 0 || score > QUESTION_COUNT; })) return null; + return { kind: "exam", choice: choice, scores: scores, passed: scores.every(function (score) { return score >= 19; }) }; + } + + function copyText(value) { + if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(value); + var input = document.createElement("textarea"); + input.value = value; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + var copied = document.execCommand("copy"); + input.remove(); + return copied ? Promise.resolve() : Promise.reject(new Error("Kopieren nicht möglich")); } function shuffled(values) { @@ -175,6 +177,8 @@ var session = ref(null); var view = ref("exam"); var reviewPartIndex = ref(null); + var sharedResult = ref(null); + var shareMessage = ref(""); var now = ref(Date.now()); var timerId = null; var catalogLoadHandler = null; @@ -191,6 +195,9 @@ }); var currentChoice = computed(function () { if (!session.value) return null; + if (session.value.mode === "homework") { + return { id: "H", label: "Hausaufgabe", parts: ["H"] }; + } return EXAM_CHOICES.find(function (choice) { return choice.id === session.value.choiceId; }); }); var displayedPartIndex = computed(function () { @@ -204,8 +211,14 @@ if (!currentPart.value) return null; return PART_DEFINITIONS[currentPart.value.code]; }); + var isHomework = computed(function () { + return Boolean(session.value && session.value.mode === "homework"); + }); + var currentQuestionCount = computed(function () { + return currentPart.value ? currentPart.value.questions.length : 0; + }); var remainingSeconds = computed(function () { - if (!currentPart.value || currentPart.value.status !== "running") return 0; + if (isHomework.value || !currentPart.value || currentPart.value.status !== "running") return 0; if (currentPart.value.paused) return currentPart.value.pausedRemainingSeconds; return Math.max(0, Math.ceil((currentPart.value.deadline - now.value) / 1000)); }); @@ -229,6 +242,7 @@ } var overallState = computed(function () { + if (isHomework.value) return "homework"; if (session.value.parts.some(function (part) { return part.status !== "evaluated"; })) return "incomplete"; var failed = session.value.parts.filter(function (part) { return part.score < 19; }); if (failed.length === 0) return "passed"; @@ -236,15 +250,19 @@ return "failed"; }); var overallAlertClass = computed(function () { - return { passed: "alert-success", oral: "alert-warning", failed: "alert-danger", incomplete: "alert-danger" }[overallState.value]; + return { homework: "alert-primary", passed: "alert-success", oral: "alert-warning", failed: "alert-danger", incomplete: "alert-danger" }[overallState.value]; }); var overallIcon = computed(function () { - return { passed: "verified", oral: "record_voice_over", failed: "cancel", incomplete: "stop_circle" }[overallState.value]; + return { homework: "task_alt", passed: "verified", oral: "record_voice_over", failed: "cancel", incomplete: "stop_circle" }[overallState.value]; }); var overallTitle = computed(function () { - return { passed: "Prüfung bestanden", oral: "Mündliche Nachprüfung möglich", failed: "Prüfung nicht bestanden", incomplete: "Prüfung vorzeitig beendet" }[overallState.value]; + return { homework: "Hausaufgabe ausgewertet", passed: "Prüfung bestanden", oral: "Mündliche Nachprüfung möglich", failed: "Prüfung nicht bestanden", incomplete: "Prüfung vorzeitig beendet" }[overallState.value]; }); var overallText = computed(function () { + if (overallState.value === "homework") { + var homeworkPart = session.value.parts[0]; + return homeworkPart.score + " von " + homeworkPart.questions.length + " Fragen wurden richtig beantwortet."; + } if (overallState.value === "passed") return "Gratulation! Du hast jeden Prüfungsteil mit mindestens 19 Punkten bestanden."; if (overallState.value === "oral") return "In genau einem Prüfungsteil wurden 17 oder 18 Punkte erreicht; nach den Prüfungsregeln kann eine mündliche Nachprüfung möglich sein."; if (overallState.value === "failed") return "Mindestens ein Prüfungsteil wurde nicht bestanden."; @@ -259,6 +277,10 @@ return String(number).padStart(2, "0"); } + function displayQuestionNumber(questionState, questionIndex) { + return isHomework.value ? questionState.id : padNumber(questionIndex + 1); + } + function pictureAlt(picture, fallback) { return altTexts[picture] || fallback; } @@ -305,9 +327,29 @@ }; } + function createHomeworkPart(questionIds) { + var questions = questionIds.map(function (questionId) { return catalog.value[questionId]; }).filter(Boolean); + if (questions.length !== questionIds.length) { + throw new Error("Mindestens eine Frage aus dem Hausaufgabenlink ist nicht im aktuellen Fragenkatalog enthalten."); + } + if (questions.length === 0) throw new Error("Der Hausaufgabenlink enthält keine gültigen Fragen."); + return { + code: "H", + status: "pending", + deadline: null, + paused: false, + pausedRemainingSeconds: null, + score: null, + autoSubmitted: false, + questions: questions.map(function (question) { + return { id: question.id, order: shuffled([0, 1, 2, 3]), selected: null, history: [] }; + }) + }; + } + function activatePart(part) { part.status = "running"; - part.deadline = Date.now() + PART_DEFINITIONS[part.code].minutes * 60 * 1000; + part.deadline = part.code === "H" ? null : Date.now() + PART_DEFINITIONS[part.code].minutes * 60 * 1000; part.paused = false; part.pausedRemainingSeconds = null; now.value = Date.now(); @@ -315,7 +357,7 @@ function toggleTimer() { var part = currentPart.value; - if (!part || part.status !== "running") return; + if (isHomework.value || !part || part.status !== "running") return; if (part.paused) { part.deadline = Date.now() + part.pausedRemainingSeconds * 1000; part.pausedRemainingSeconds = null; @@ -340,6 +382,7 @@ activatePart(parts[0]); session.value = { version: STORAGE_VERSION, + mode: "exam", choiceId: choice.id, currentPart: 0, startedAt: Date.now(), @@ -353,6 +396,27 @@ } } + function startHomework(questionIds) { + try { + var part = createHomeworkPart(questionIds); + activatePart(part); + session.value = { + version: STORAGE_VERSION, + mode: "homework", + choiceId: "H", + homeworkIds: questionIds.slice(), + currentPart: 0, + startedAt: Date.now(), + parts: [part] + }; + view.value = "exam"; + reviewPartIndex.value = null; + renderMathSoon(Vue); + } catch (error) { + loadError.value = error.message; + } + } + function selectAnswer(questionIndex, answerIndex) { if (currentPart.value.status !== "running" || currentPart.value.paused) return; var question = currentPart.value.questions[questionIndex]; @@ -414,10 +478,10 @@ } function requestSubmit() { - var unanswered = QUESTION_COUNT - answeredCount.value; + var unanswered = currentQuestionCount.value - answeredCount.value; var text = unanswered > 0 - ? "Du hast " + unanswered + " Fragen nicht beantwortet. Möchtest du den Prüfungsteil trotzdem abgeben?" - : "Möchtest du diesen Prüfungsteil jetzt verbindlich abgeben?"; + ? "Du hast " + unanswered + " Fragen nicht beantwortet. Möchtest du " + (isHomework.value ? "die Hausaufgabe" : "den Prüfungsteil") + " trotzdem abgeben?" + : "Möchtest du " + (isHomework.value ? "die Hausaufgabe" : "diesen Prüfungsteil") + " jetzt verbindlich abgeben?"; if (window.confirm(text)) submitPart(false); } @@ -463,16 +527,29 @@ session.value = null; view.value = "exam"; reviewPartIndex.value = null; + sharedResult.value = null; + shareMessage.value = ""; window.scrollTo({ top: 0, behavior: "smooth" }); } function abortExam() { - if (!window.confirm("Möchtest du diese Simulation abbrechen? Der gespeicherte Prüfungsstand wird gelöscht.")) return; + var noun = isHomework.value ? "Hausaufgabe" : "Simulation"; + if (!window.confirm("Möchtest du diese " + noun + " abbrechen? Der gespeicherte Stand wird gelöscht.")) return; resetExam(); + window.history.replaceState(null, "", "simulation.html"); } function newExam() { resetExam(); + window.history.replaceState(null, "", "simulation.html"); + } + + function retryHomework() { + var questionIds = session.value && session.value.homeworkIds; + if (!questionIds) return; + clearStoredSession(); + startHomework(questionIds); + window.scrollTo({ top: 0, behavior: "smooth" }); } function scrollToQuestion(index) { @@ -481,37 +558,113 @@ } function resultLabel(part) { + if (part.code === "H") return "Ausgewertet"; if (part.score >= 19) return "Bestanden"; if (part.score >= 17) return "Nachprüfung möglich"; return "Nicht bestanden"; } function resultAlertClass(part) { + if (part.code === "H") return "alert-primary"; if (part.score >= 19) return "alert-success"; if (part.score >= 17) return "alert-warning"; return "alert-danger"; } function resultBadgeClass(part) { + if (part.code === "H") return "text-bg-primary"; if (part.score >= 19) return "text-bg-success"; if (part.score >= 17) return "text-bg-warning"; return "text-bg-danger"; } function resultIcon(part) { + if (part.code === "H") return "task_alt"; if (part.score >= 19) return "check_circle"; if (part.score >= 17) return "record_voice_over"; return "cancel"; } function resultDescription(part) { + if (part.code === "H") return part.score + " von " + part.questions.length + " Fragen wurden richtig beantwortet."; var prefix = part.autoSubmitted ? "Die Zeit ist abgelaufen und der Prüfungsteil wurde automatisch abgegeben. " : ""; if (part.score >= 19) return prefix + "Die erforderlichen 19 Punkte wurden erreicht."; if (part.score >= 17) return prefix + "Mit 17 oder 18 Punkten kann nach den Prüfungsregeln eine mündliche Nachprüfung möglich sein."; return prefix + "Die erforderlichen 19 Punkte wurden nicht erreicht."; } - function restoreSession() { + function encodedResult(result) { + if (result.kind === "homework") return "1.H." + result.score + "." + result.total; + return "1." + result.choice.id + "." + result.scores.join("."); + } + + var resultForSharing = computed(function () { + if (sharedResult.value) return sharedResult.value; + if (!session.value || view.value !== "summary") return null; + if (isHomework.value) { + return { kind: "homework", score: session.value.parts[0].score, total: session.value.parts[0].questions.length }; + } + if (session.value.parts.some(function (part) { return part.status !== "evaluated"; })) return null; + return { + kind: "exam", + choice: currentChoice.value, + scores: session.value.parts.map(function (part) { return part.score; }), + passed: overallState.value === "passed" + }; + }); + + var resultShareUrl = computed(function () { + if (!resultForSharing.value) return ""; + var url = new URL("simulation.html", window.location.href); + url.search = "?result=" + encodedResult(resultForSharing.value); + return url.toString(); + }); + + var shareText = computed(function () { + var result = resultForSharing.value; + if (!result) return ""; + if (result.kind === "homework") { + return "Meine 50ohm.de-Hausaufgabe: " + result.score + " von " + result.total + " Fragen richtig."; + } + var scores = result.choice.parts.map(function (part, index) { return part + ": " + result.scores[index] + "/25"; }).join(", "); + return "Meine 50ohm.de-Prüfungssimulation " + result.choice.label + ": " + scores + " – " + (result.passed ? "bestanden!" : "noch nicht bestanden."); + }); + + var socialShareLinks = computed(function () { + var textWithUrl = shareText.value + " " + resultShareUrl.value; + return { + mastodon: "https://share.joinmastodon.org/#text=" + encodeURIComponent(textWithUrl), + bluesky: "https://bsky.app/intent/compose?text=" + encodeURIComponent(textWithUrl), + facebook: "https://www.facebook.com/sharer/sharer.php?u=" + encodeURIComponent(resultShareUrl.value), + x: "https://x.com/intent/tweet?text=" + encodeURIComponent(textWithUrl) + }; + }); + + function copyResultLink() { + copyText(resultShareUrl.value) + .then(function () { shareMessage.value = "Ergebnislink kopiert."; }) + .catch(function () { shareMessage.value = "Der Link konnte nicht automatisch kopiert werden."; }); + } + + function shareNative() { + if (!navigator.share) { + copyResultLink(); + return; + } + navigator.share({ title: "50ohm.de-Ergebnis", text: shareText.value, url: resultShareUrl.value }).catch(function () {}); + } + + function shareMatrix() { + if (navigator.share) { + navigator.share({ title: "50ohm.de-Ergebnis", text: shareText.value, url: resultShareUrl.value }).catch(function () {}); + return; + } + copyText(shareText.value + " " + resultShareUrl.value).finally(function () { + window.open("https://chat.darc.de", "_blank", "noopener"); + }); + } + + function restoreSession(homeworkQuestionIds) { try { var raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return; @@ -520,9 +673,12 @@ clearStoredSession(); return; } + var homeworkMode = stored.session.mode === "homework"; var choice = EXAM_CHOICES.find(function (item) { return item.id === stored.session.choiceId; }); - var valid = choice && stored.session.parts.every(function (part) { - return Array.isArray(part.questions) && part.questions.length === QUESTION_COUNT && part.questions.every(function (question) { + var homeworkMatches = homeworkMode && homeworkQuestionIds.length > 0 && + JSON.stringify(stored.session.homeworkIds) === JSON.stringify(homeworkQuestionIds); + var valid = (homeworkMatches || choice) && stored.session.parts.every(function (part) { + return Array.isArray(part.questions) && (homeworkMode || part.questions.length === QUESTION_COUNT) && part.questions.every(function (question) { return Boolean(catalog.value[question.id]); }); }); @@ -535,6 +691,7 @@ if (!part.paused) part.pausedRemainingSeconds = null; }); session.value = stored.session; + if (!session.value.mode) session.value.mode = "exam"; view.value = stored.view === "summary" ? "summary" : "exam"; if (session.value.parts[session.value.currentPart].status === "running" && !session.value.parts[session.value.currentPart].paused && @@ -562,7 +719,7 @@ }); watch(remainingSeconds, function (seconds) { - if (session.value && currentPart.value && currentPart.value.status === "running" && + if (!isHomework.value && session.value && currentPart.value && currentPart.value.status === "running" && !currentPart.value.paused && seconds <= 0) submitPart(true); }); @@ -580,9 +737,18 @@ return Promise.all(responses.map(function (response) { return response.json(); })); }) .then(function (data) { - catalog.value = normalizeCatalog(data[0], data[1] || {}, data[2] || {}); + catalog.value = window.FiftyOhmQuestions.normalizeCatalog(data[0], data[1] || {}, data[2] || {}); if (Object.keys(catalog.value).length === 0) throw new Error("Der Fragenkatalog ist leer."); - restoreSession(); + var parsedResult = parseSharedResult(); + var homeworkQuestionIds = parseHomeworkQuestionIds(); + if (queryValue("result")) { + if (!parsedResult) throw new Error("Der Ergebnislink ist ungültig."); + sharedResult.value = parsedResult; + view.value = "shared"; + } else { + restoreSession(homeworkQuestionIds); + if (homeworkQuestionIds.length > 0 && !session.value) startHomework(homeworkQuestionIds); + } loading.value = false; renderMathSoon(Vue); }) @@ -627,12 +793,16 @@ loadError: loadError, session: session, view: view, + sharedResult: sharedResult, + shareMessage: shareMessage, primaryChoices: primaryChoices, upgradeChoices: upgradeChoices, singlePartChoices: singlePartChoices, currentChoice: currentChoice, currentPart: currentPart, currentPartDefinition: currentPartDefinition, + isHomework: isHomework, + currentQuestionCount: currentQuestionCount, displayedPartIndex: displayedPartIndex, partDefinitions: PART_DEFINITIONS, answerLabels: ANSWER_LABELS, @@ -648,6 +818,7 @@ overallTitle: overallTitle, overallText: overallText, padNumber: padNumber, + displayQuestionNumber: displayQuestionNumber, pictureAlt: pictureAlt, questionData: questionData, startExam: startExam, @@ -662,12 +833,20 @@ reviewPart: reviewPart, abortExam: abortExam, newExam: newExam, + retryHomework: retryHomework, scrollToQuestion: scrollToQuestion, resultLabel: resultLabel, resultAlertClass: resultAlertClass, resultBadgeClass: resultBadgeClass, resultIcon: resultIcon, - resultDescription: resultDescription + resultDescription: resultDescription, + resultForSharing: resultForSharing, + resultShareUrl: resultShareUrl, + shareText: shareText, + socialShareLinks: socialShareLinks, + copyResultLink: copyResultLink, + shareNative: shareNative, + shareMatrix: shareMatrix }; } }); diff --git a/assets/homework.css b/assets/homework.css new file mode 100644 index 0000000..f1bc81c --- /dev/null +++ b/assets/homework.css @@ -0,0 +1,16 @@ +.homework-generator { + max-width: 1000px; +} + +.homework-steps .badge { + font-size: 0.9rem; + font-weight: 500; +} + +.homework-question { + cursor: pointer; +} + +.homework-question:hover { + background: #f8f9fa; +} diff --git a/assets/homework.js b/assets/homework.js new file mode 100644 index 0000000..b500b8b --- /dev/null +++ b/assets/homework.js @@ -0,0 +1,243 @@ +(function () { + "use strict"; + + var COURSE_CHOICES = [ + { edition: "N", label: "N-Kurs", detail: "N", color: "#47ABE8" }, + { edition: "NE", label: "E-Kurs", detail: "N + E", color: "#FE756C" }, + { edition: "NEA", label: "A-Kurs", detail: "N + E + A", color: "#3BB583" }, + { edition: "E", label: "Aufstockung N → E", detail: "E", color: "#FE756C" }, + { edition: "EA", label: "Aufstockung N → A", detail: "E + A", color: "#3BB583" }, + { edition: "A", label: "Aufstockung E → A", detail: "A", color: "#3BB583" } + ]; + + function renderMathSoon() { + Vue.nextTick(function () { + if (typeof window.renderFiftyOhmMath !== "function") return; + window.renderFiftyOhmMath(document.getElementById("homework-generator")); + }); + } + + function copyText(text) { + if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); + var input = document.createElement("textarea"); + input.value = text; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + var copied = document.execCommand("copy"); + input.remove(); + return copied ? Promise.resolve() : Promise.reject(new Error("Kopieren nicht möglich")); + } + + var app = Vue.createApp({ + setup: function () { + var ref = Vue.ref; + var computed = Vue.computed; + var reactive = Vue.reactive; + + var loading = ref(true); + var loadError = ref(""); + var step = ref(1); + var catalog = ref({}); + var questionIndex = ref({}); + var questionsBySection = ref({}); + var selectedCourse = ref(null); + var toc = ref(null); + var selectedChapterIds = ref([]); + var selectedSectionIds = ref([]); + var questionSelection = reactive({}); + var generatedUrl = ref(""); + var copyMessage = ref(""); + var tocCache = {}; + + var stepLabels = [ + { number: 1, label: "Kurs" }, + { number: 2, label: "Kapitel" }, + { number: 3, label: "Sektionen" }, + { number: 4, label: "Fragen" }, + { number: 5, label: "Link" } + ]; + + function sectionQuestionIds(section) { + if (!selectedCourse.value) return []; + return questionsBySection.value[selectedCourse.value.edition + ":" + section.ident] || []; + } + + function chapterQuestionCount(chapter) { + return chapter.sections.reduce(function (sum, section) { + return sum + sectionQuestionIds(section).length; + }, 0); + } + + var selectedChapters = computed(function () { + if (!toc.value) return []; + return toc.value.chapters.filter(function (chapter) { + return selectedChapterIds.value.indexOf(chapter.ident) !== -1; + }); + }); + + var selectedSectionGroups = computed(function () { + var groups = []; + selectedChapters.value.forEach(function (chapter) { + chapter.sections.forEach(function (section) { + if (selectedSectionIds.value.indexOf(section.ident) === -1) return; + groups.push({ chapter: chapter, section: section, questionIds: sectionQuestionIds(section) }); + }); + }); + return groups; + }); + + var orderedQuestions = computed(function () { + return selectedSectionGroups.value.reduce(function (result, group) { + return result.concat(group.questionIds); + }, []); + }); + + var selectedQuestionIds = computed(function () { + return orderedQuestions.value.filter(function (questionId) { return questionSelection[questionId]; }); + }); + + function buildSectionIndex() { + var result = {}; + Object.keys(questionIndex.value).forEach(function (questionId) { + var entry = questionIndex.value[questionId]; + (entry.editions || []).forEach(function (edition) { + var key = edition + ":" + entry.section; + if (!result[key]) result[key] = []; + result[key].push(questionId); + }); + }); + Object.keys(result).forEach(function (key) { + result[key].sort(function (left, right) { + return left.localeCompare(right, "de", { numeric: true }); + }); + }); + questionsBySection.value = result; + } + + function goToStep(number) { + step.value = number; + copyMessage.value = ""; + if (number === 4) renderMathSoon(); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + function selectCourse(course) { + selectedCourse.value = course; + selectedChapterIds.value = []; + selectedSectionIds.value = []; + generatedUrl.value = ""; + if (tocCache[course.edition]) { + toc.value = tocCache[course.edition]; + goToStep(2); + return; + } + loading.value = true; + fetch("assets/toc/" + course.edition + ".json") + .then(function (response) { + if (!response.ok) throw new Error("Inhaltsverzeichnis nicht gefunden (HTTP " + response.status + ")."); + return response.json(); + }) + .then(function (data) { + tocCache[course.edition] = data; + toc.value = data; + loading.value = false; + goToStep(2); + }) + .catch(function (error) { + loadError.value = error.message || "Unbekannter Ladefehler"; + loading.value = false; + }); + } + + function openSectionSelection() { + var preselectedSections = []; + selectedChapters.value.forEach(function (chapter) { + chapter.sections.forEach(function (section) { + if (sectionQuestionIds(section).length > 0) preselectedSections.push(section.ident); + }); + }); + selectedSectionIds.value = preselectedSections; + goToStep(3); + } + + function openQuestionSelection() { + orderedQuestions.value.forEach(function (questionId) { questionSelection[questionId] = true; }); + goToStep(4); + } + + function setAllQuestions(selected) { + orderedQuestions.value.forEach(function (questionId) { questionSelection[questionId] = selected; }); + } + + function generateLink() { + var url = new URL("simulation.html", window.location.href); + url.search = "?homework=" + selectedQuestionIds.value.join("+"); + generatedUrl.value = url.toString(); + goToStep(5); + } + + function copyLink() { + copyText(generatedUrl.value) + .then(function () { copyMessage.value = "Der Hausaufgabenlink wurde kopiert."; }) + .catch(function () { copyMessage.value = "Der Link konnte nicht automatisch kopiert werden. Bitte kopiere ihn aus dem Feld."; }); + } + + Promise.all([ + fetch("assets/fragenkatalog.json"), + fetch("assets/metadata.json"), + fetch("assets/question_index.json") + ]) + .then(function (responses) { + var names = ["Fragenkatalog", "Metadaten", "Fragenindex"]; + responses.forEach(function (response, index) { + if (!response.ok) throw new Error(names[index] + " nicht gefunden (HTTP " + response.status + ")."); + }); + return Promise.all(responses.map(function (response) { return response.json(); })); + }) + .then(function (data) { + questionIndex.value = data[2] || {}; + catalog.value = window.FiftyOhmQuestions.normalizeCatalog(data[0], data[1] || {}, questionIndex.value); + buildSectionIndex(); + loading.value = false; + }) + .catch(function (error) { + loadError.value = error.message || "Unbekannter Ladefehler"; + loading.value = false; + }); + + return { + loading: loading, + loadError: loadError, + step: step, + stepLabels: stepLabels, + courseChoices: COURSE_CHOICES, + catalog: catalog, + selectedCourse: selectedCourse, + toc: toc, + selectedChapterIds: selectedChapterIds, + selectedSectionIds: selectedSectionIds, + questionSelection: questionSelection, + selectedChapters: selectedChapters, + selectedSectionGroups: selectedSectionGroups, + orderedQuestions: orderedQuestions, + selectedQuestionIds: selectedQuestionIds, + generatedUrl: generatedUrl, + copyMessage: copyMessage, + sectionQuestionIds: sectionQuestionIds, + chapterQuestionCount: chapterQuestionCount, + goToStep: goToStep, + selectCourse: selectCourse, + openSectionSelection: openSectionSelection, + openQuestionSelection: openQuestionSelection, + setAllQuestions: setAllQuestions, + generateLink: generateLink, + copyLink: copyLink + }; + } + }); + + app.mount("#homework-generator"); +})(); diff --git a/assets/question-catalog.js b/assets/question-catalog.js new file mode 100644 index 0000000..4b682f5 --- /dev/null +++ b/assets/question-catalog.js @@ -0,0 +1,52 @@ +(function () { + "use strict"; + + function normalizeCatalog(rawCatalog, metadata, questionIndex) { + var questions = {}; + var partForSection = { 1: "B", 2: "V" }; + var partForClass = { "1": "N", "2": "E", "3": "A" }; + + function pictureId(value) { + return value ? String(value) : ""; + } + + function walk(section, sectionIndex, category) { + (section.questions || []).forEach(function (question) { + var part = sectionIndex === 0 + ? partForClass[String(question.class)] + : partForSection[sectionIndex]; + if (!part) return; + + var questionMetadata = metadata[question.number] || {}; + questions[question.number] = { + id: question.number, + part: part, + category: category.slice(), + question: question.question, + layout: questionMetadata.layout || "default", + picture: pictureId(questionMetadata.picture_question), + answers: ["a", "b", "c", "d"].map(function (letter) { + return { + text: question["answer_" + letter] || "", + picture: pictureId(questionMetadata["picture_" + letter]) + }; + }), + hasSolution: Boolean(questionIndex[question.number] && questionIndex[question.number].has_solution) + }; + }); + + (section.sections || []).forEach(function (child, childIndex) { + walk(child, sectionIndex, category.concat(childIndex)); + }); + } + + (rawCatalog.sections || []).forEach(function (section, sectionIndex) { + walk(section, sectionIndex, []); + }); + return questions; + } + + window.FiftyOhmQuestions = { + normalizeCatalog: normalizeCatalog + }; +})(); diff --git a/src/build.py b/src/build.py index 62d2f11..33af3cb 100644 --- a/src/build.py +++ b/src/build.py @@ -159,12 +159,35 @@ class Build: content = exam_template.render() page = self.__build_page( content, - page_stylesheets=["assets/exam-simulator.css?ts=202608051415"], - page_scripts=["assets/vue.global.prod.js", "assets/exam-simulator.js?ts=202608051500"], + page_stylesheets=["assets/exam-simulator.css?ts=20260806"], + page_scripts=[ + "assets/vue.global.prod.js", + "assets/question-catalog.js?ts=20260806", + "assets/exam-simulator.js?ts=20260806", + ], ) with (self.config.p_build / "simulation.html").open("w", encoding="utf-8") as file: file.write(page) + def __build_homework_tool(self): + toc_target = self.config.p_build_assets / "toc" + toc_target.mkdir(parents=True, exist_ok=True) + for edition in ("N", "NE", "NEA", "E", "EA", "A"): + shutil.copyfile(self.config.p_data_toc / f"{edition}.json", toc_target / f"{edition}.json") + + homework_template = self.env.get_template("html/homework.html") + page = self.__build_page( + homework_template.render(), + page_stylesheets=["assets/homework.css?ts=20260806"], + page_scripts=[ + "assets/vue.global.prod.js", + "assets/question-catalog.js?ts=20260806", + "assets/homework.js?ts=20260806", + ], + ) + with (self.config.p_build / "hausaufgabe.html").open("w", encoding="utf-8") as file: + file.write(page) + def __copy_picture(self, id): file = f"{id}.svg" try: @@ -542,6 +565,7 @@ class Build: self.__build_html_page(contents, "infos") self.__build_html_page(contents, "suche") self.__build_exam_simulator() + self.__build_homework_tool() def build_solutions(self): for solution_file in self.config.p_data_solutions.glob("*.md"): diff --git a/templates/html/exam-simulator.html b/templates/html/exam-simulator.html index 4b79fbb..eb5f27c 100644 --- a/templates/html/exam-simulator.html +++ b/templates/html/exam-simulator.html @@ -9,6 +9,49 @@

+
+

Geteiltes Ergebnis

+ + +
+

Ergebnis teilen

+
+ + + + Mastodon + Bluesky + Facebook + X +
+ +
+ +
+

Prüfungssimulation BETA

Welche Prüfung möchtest du simulieren?

@@ -56,7 +99,7 @@
-

Gesamtergebnis

+

@@ -91,7 +134,7 @@ @@ -102,13 +145,30 @@
- +
+

Ergebnis teilen

+
+ + + + Mastodon + Bluesky + Facebook + X +
+ +
+
+ + +
- +

@@ -118,7 +178,7 @@ - - Abgegeben + Abgegeben + type="button" class="btn btn-primary btn-sm" @click="showSummary" v-text="isHomework ? 'Ergebnis' : 'Gesamtergebnis'"> - +
@@ -142,7 +203,7 @@ :class="resultAlertClass(currentPart)">
- von 25 Punkten – + von Punkten –
@@ -154,7 +215,7 @@
- +
-