@lernapps/app-templates 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +195 -2
- package/dist/appname-Bh23cUuz.mjs +12 -0
- package/dist/assessment-By2GGTJC.mjs +1696 -0
- package/dist/block-BK_ohsH1.mjs +82 -0
- package/dist/blocks-BmRrD7UY.mjs +149 -0
- package/dist/blocks.mjs +4 -0
- package/dist/check-BkEl-m-F.mjs +254 -0
- package/dist/content-cYBPYg_J.mjs +140 -0
- package/dist/contract-PONpxJtd.mjs +52 -0
- package/dist/e2e-BiMI-pCL.mjs +89 -0
- package/dist/engine-BqF2Vffn.mjs +136 -0
- package/dist/explainer-e2e.mjs +1297 -0
- package/dist/explainer-islands.mjs +197 -0
- package/dist/explainer-plugin.mjs +2131 -0
- package/dist/explainer.mjs +1246 -0
- package/dist/html-mffkHGHh.mjs +89 -0
- package/dist/image-CspJVfyQ.mjs +21 -0
- package/dist/interactive-e2e.mjs +140 -0
- package/dist/interactive-plugin.mjs +242 -0
- package/dist/interactive.mjs +75 -0
- package/dist/load-C-0hUaSC.mjs +16947 -0
- package/dist/needs-BzEahJ6X.mjs +97 -0
- package/dist/pages-9RaaomoO.mjs +81 -0
- package/dist/questions-e2e.mjs +1 -0
- package/dist/questions-engine.mjs +3 -0
- package/dist/questions.mjs +6 -0
- package/dist/quiz-BKc8JYtC.mjs +70 -0
- package/dist/quiz-e2e.mjs +174 -0
- package/dist/quiz-plugin.mjs +266 -0
- package/dist/quiz.mjs +246 -0
- package/dist/render-BJey0bwl.mjs +540 -0
- package/dist/render-BL6fV_NI.mjs +507 -0
- package/dist/render-DpRO_RKm.mjs +146 -0
- package/dist/result-_eQcamoz.mjs +180 -0
- package/dist/tasks-DADUYy52.mjs +74 -0
- package/dist/validate-COaZso7u.mjs +1072 -0
- package/dist/validate-CxtdeQ5S.mjs +146 -0
- package/dist/vue-DO1m6q1R.mjs +40 -0
- package/dist/vue-code-BkJjW_I_.mjs +320 -0
- package/docs/arc42/01-introduction-and-goals.arc42.md +82 -0
- package/docs/arc42/02-constraints.arc42.md +228 -0
- package/docs/arc42/03-system-context.arc42.md +118 -0
- package/docs/arc42/04-solution-strategy.arc42.md +47 -0
- package/docs/arc42/05-building-blocks.arc42.md +307 -0
- package/docs/arc42/06-runtime-view.arc42.md +98 -0
- package/docs/arc42/07-deployment-view.arc42.md +160 -0
- package/docs/arc42/08-concepts.arc42.md +123 -0
- package/docs/arc42/09-decisions.arc42.md +246 -0
- package/docs/arc42/10-quality-requirements.arc42.md +155 -0
- package/docs/arc42/11-risks.arc42.md +113 -0
- package/docs/arc42/12-glossary.arc42.md +162 -0
- package/docs/arc42/architecture-evidence.md +395 -0
- package/explainer/.arc42ignore +3 -0
- package/explainer/README.md +54 -0
- package/explainer/_gitignore +5 -0
- package/explainer/docs/arc42/03-explainer-system-context.arc42.md +35 -0
- package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +659 -0
- package/explainer/docs/arc42/06-explainer-runtime-view.arc42.md +105 -0
- package/explainer/docs/arc42/07-explainer-deployment-view.arc42.md +34 -0
- package/explainer/docs/arc42/08-explainer-concepts.arc42.md +17 -0
- package/explainer/docs/arc42/09-explainer-decisions.arc42.md +443 -0
- package/explainer/docs/arc42/10-explainer-quality-requirements.arc42.md +54 -0
- package/explainer/docs/arc42/11-explainer-risks.arc42.md +17 -0
- package/explainer/docs/arc42/12-explainer-glossary.arc42.md +185 -0
- package/explainer/e2e/explainer.e2e.ts +3 -0
- package/explainer/index.html +11 -0
- package/explainer/package.json +18 -0
- package/explainer/playwright.config.ts +1 -0
- package/explainer/src/app.yaml +20 -0
- package/explainer/src/components/ProzentFeld.test.ts +30 -0
- package/explainer/src/components/ProzentFeld.ts +121 -0
- package/explainer/src/components/ProzentFeld.vue +84 -0
- package/explainer/src/images/hunderterfeld-klammern.svg +105 -0
- package/explainer/src/main.ts +4 -0
- package/explainer/src/prozent/chapter.yaml +18 -0
- package/explainer/src/prozent/grundwert.yaml +115 -0
- package/explainer/src/prozent/prozentsatz.yaml +99 -0
- package/explainer/src/prozent/prozentwert.yaml +119 -0
- package/explainer/tsconfig.json +1 -0
- package/explainer/vite.config.ts +4 -0
- package/interactive/.arc42ignore +5 -0
- package/interactive/README.md +28 -0
- package/interactive/_gitignore +4 -0
- package/interactive/docs/arc42/05-interactive-building-blocks.arc42.md +518 -0
- package/interactive/docs/arc42/06-interactive-runtime-view.arc42.md +58 -0
- package/interactive/docs/arc42/07-interactive-deployment-view.arc42.md +35 -0
- package/interactive/docs/arc42/08-interactive-concepts.arc42.md +52 -0
- package/interactive/docs/arc42/09-interactive-decisions.arc42.md +130 -0
- package/interactive/docs/arc42/10-interactive-quality-requirements.arc42.md +45 -0
- package/interactive/docs/arc42/11-interactive-risks.arc42.md +20 -0
- package/interactive/docs/arc42/12-interactive-glossary.arc42.md +80 -0
- package/interactive/e2e/interactive.e2e.ts +3 -0
- package/interactive/index.html +11 -0
- package/interactive/package.json +18 -0
- package/interactive/playwright.config.ts +1 -0
- package/interactive/src/app.yaml +28 -0
- package/interactive/src/core/index.ts +101 -0
- package/interactive/src/data/lessons.json +4 -0
- package/interactive/src/env.d.ts +6 -0
- package/interactive/src/main.ts +6 -0
- package/interactive/src/messages/de.json +12 -0
- package/interactive/src/playthroughs/grundreihe.yaml +33 -0
- package/interactive/src/playthroughs/obere-reihe.yaml +14 -0
- package/interactive/src/ui/App.vue +85 -0
- package/interactive/tsconfig.json +1 -0
- package/interactive/vite.config.ts +4 -0
- package/package.json +144 -4
- package/quiz/README.md +31 -0
- package/quiz/_gitignore +5 -0
- package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +283 -0
- package/quiz/docs/arc42/06-quiz-runtime-view.arc42.md +21 -0
- package/quiz/docs/arc42/07-quiz-deployment-view.arc42.md +33 -0
- package/quiz/docs/arc42/09-quiz-decisions.arc42.md +36 -0
- package/quiz/docs/arc42/10-quiz-quality-requirements.arc42.md +53 -0
- package/quiz/docs/arc42/11-quiz-risks.arc42.md +19 -0
- package/quiz/docs/arc42/12-quiz-glossary.arc42.md +27 -0
- package/quiz/e2e/quiz.e2e.ts +3 -0
- package/quiz/index.html +11 -0
- package/quiz/package.json +18 -0
- package/quiz/playwright.config.ts +1 -0
- package/quiz/src/images/berge-ordnen.jpg +0 -0
- package/quiz/src/images/everest-hoehe.jpg +0 -0
- package/quiz/src/images/galapagos.jpg +0 -0
- package/quiz/src/images/grand-canyon.jpg +0 -0
- package/quiz/src/images/totes-meer.jpg +0 -0
- package/quiz/src/images/uluru.jpg +0 -0
- package/quiz/src/images/wasserfaelle-laender.jpg +0 -0
- package/quiz/src/main.ts +4 -0
- package/quiz/src/quiz.json +312 -0
- package/quiz/tsconfig.json +1 -0
- package/quiz/vite.config.ts +4 -0
- package/runtime/explainer/assessment-client.ts +478 -0
- package/runtime/explainer/assessment-page.ts +446 -0
- package/runtime/explainer/assessment.ts +393 -0
- package/runtime/explainer/check-page.ts +520 -0
- package/runtime/explainer/components/exercise.ts +409 -0
- package/runtime/explainer/components/props.ts +230 -0
- package/runtime/explainer/components/read.ts +428 -0
- package/runtime/explainer/content.ts +277 -0
- package/runtime/explainer/e2e.ts +1474 -0
- package/runtime/explainer/exercises.ts +324 -0
- package/runtime/explainer/explainer.css +1283 -0
- package/runtime/explainer/explainer.ts +838 -0
- package/runtime/explainer/explainer.v1.schema.json +1165 -0
- package/runtime/explainer/expression.ts +407 -0
- package/runtime/explainer/fraction.ts +107 -0
- package/runtime/explainer/generated/comparator.ts +31 -0
- package/runtime/explainer/generated/question.ts +165 -0
- package/runtime/explainer/generated/soundness.ts +280 -0
- package/runtime/explainer/generated/texts.ts +257 -0
- package/runtime/explainer/generated/types.ts +103 -0
- package/runtime/explainer/generated/values.ts +136 -0
- package/runtime/explainer/islands.ts +281 -0
- package/runtime/explainer/load.ts +457 -0
- package/runtime/explainer/messages/de.json +200 -0
- package/runtime/explainer/messages/en.json +200 -0
- package/runtime/explainer/messages.ts +13 -0
- package/runtime/explainer/needs.ts +122 -0
- package/runtime/explainer/open-task.ts +235 -0
- package/runtime/explainer/outline.ts +299 -0
- package/runtime/explainer/plugin.ts +488 -0
- package/runtime/explainer/position.ts +62 -0
- package/runtime/explainer/prompt.ts +75 -0
- package/runtime/explainer/render.ts +686 -0
- package/runtime/explainer/result.ts +238 -0
- package/runtime/explainer/routes.ts +119 -0
- package/runtime/explainer/schema.ts +140 -0
- package/runtime/explainer/scope.ts +87 -0
- package/runtime/explainer/stuck.ts +57 -0
- package/runtime/explainer/tasks.ts +109 -0
- package/runtime/explainer/tutor.ts +469 -0
- package/runtime/explainer/tutorpage.ts +669 -0
- package/runtime/explainer/video.ts +123 -0
- package/runtime/interactive/contract.ts +121 -0
- package/runtime/interactive/e2e.ts +190 -0
- package/runtime/interactive/interactive.css +82 -0
- package/runtime/interactive/interactive.ts +115 -0
- package/runtime/interactive/interactive.v1.schema.json +146 -0
- package/runtime/interactive/load.ts +188 -0
- package/runtime/interactive/messages/de.json +14 -0
- package/runtime/interactive/messages/en.json +14 -0
- package/runtime/interactive/messages.ts +11 -0
- package/runtime/interactive/playthrough.v1.schema.json +143 -0
- package/runtime/interactive/plugin.ts +266 -0
- package/runtime/interactive/render.ts +126 -0
- package/runtime/quiz/e2e.ts +233 -0
- package/runtime/quiz/engine.ts +242 -0
- package/runtime/quiz/messages/de.json +23 -0
- package/runtime/quiz/messages/en.json +23 -0
- package/runtime/quiz/plugin.ts +150 -0
- package/runtime/quiz/quiz.css +154 -0
- package/runtime/quiz/quiz.ts +56 -0
- package/runtime/quiz/quiz.v1.schema.json +72 -0
- package/runtime/quiz/render.ts +173 -0
- package/runtime/quiz/validate.ts +108 -0
- package/runtime/shared/appname.ts +16 -0
- package/runtime/shared/blocks/block.ts +162 -0
- package/runtime/shared/blocks/blocks.css +190 -0
- package/runtime/shared/blocks/blocks.v1.schema.json +432 -0
- package/runtime/shared/blocks/code.ts +26 -0
- package/runtime/shared/blocks/figures.ts +49 -0
- package/runtime/shared/blocks/image.ts +25 -0
- package/runtime/shared/blocks/index.ts +13 -0
- package/runtime/shared/blocks/messages/de.json +4 -0
- package/runtime/shared/blocks/messages/en.json +4 -0
- package/runtime/shared/blocks/render.ts +134 -0
- package/runtime/shared/blocks/validate.ts +165 -0
- package/runtime/shared/content.ts +197 -0
- package/runtime/shared/examples.ts +20 -0
- package/runtime/shared/html.ts +10 -0
- package/runtime/shared/languages.ts +94 -0
- package/runtime/shared/page.css +101 -0
- package/runtime/shared/pages.ts +114 -0
- package/runtime/shared/questions/e2e.ts +115 -0
- package/runtime/shared/questions/engine.ts +209 -0
- package/runtime/shared/questions/index.ts +39 -0
- package/runtime/shared/questions/messages/de.json +26 -0
- package/runtime/shared/questions/messages/en.json +26 -0
- package/runtime/shared/questions/question.ts +430 -0
- package/runtime/shared/questions/question.v1.schema.json +342 -0
- package/runtime/shared/questions/questions.css +192 -0
- package/runtime/shared/questions/render.ts +247 -0
- package/runtime/shared/questions/validate.ts +518 -0
- package/runtime/shared/schema.ts +44 -0
- package/runtime/shared/text/check.ts +68 -0
- package/runtime/shared/text/formula.ts +69 -0
- package/runtime/shared/text/markdown.ts +185 -0
- package/runtime/shared/text/text.v1.schema.json +34 -0
- package/runtime/shared/vue-code.ts +395 -0
- package/runtime/shared/vue.ts +38 -0
|
@@ -0,0 +1,1246 @@
|
|
|
1
|
+
import { t as escape } from "./html-mffkHGHh.mjs";
|
|
2
|
+
import { S as shuffled, a as renderQuestion, g as questionId, l as isComplete, u as isCorrect } from "./render-BJey0bwl.mjs";
|
|
3
|
+
import { n as startQuestion, t as readAnswer } from "./engine-BqF2Vffn.mjs";
|
|
4
|
+
import { D as generate, F as questionFor, G as taskText, N as mistakeOf, W as stepsOf, c as isStoredResult, f as resultQuery, i as assessmentPrompt, j as diagnose, l as linkLines, n as BAND_MESSAGES, u as pageResults, w as message, x as startVideos, z as hintOf } from "./assessment-By2GGTJC.mjs";
|
|
5
|
+
import { i as sessionLines, n as answerShown, r as resultLine, t as Progress } from "./result-_eQcamoz.mjs";
|
|
6
|
+
import { c as addressOf, d as numberFromAddress, f as randomTaskNumber, i as NEW_TASK_ICON, n as ASSESSMENT_TASK_EVENT, p as taskNumber, r as ASSESSMENT_UNPACKED_EVENT, s as SHOWN_EVENT, t as ASSESSMENT_ANSWER_EVENT, u as nextTaskNumber } from "./tasks-DADUYy52.mjs";
|
|
7
|
+
import { a as isNeedsChoice, c as withNeeds, r as NEEDS_KEY, s as withLanguage } from "./needs-BzEahJ6X.mjs";
|
|
8
|
+
import { moveFocus } from "@lernapps/tooling/a11y";
|
|
9
|
+
import { createSettings, createStorage } from "@lernapps/tooling/storage";
|
|
10
|
+
// runtime/explainer/open-task.ts
|
|
11
|
+
const isKept = (value) => {
|
|
12
|
+
if (typeof value !== "object" || value === null) return false;
|
|
13
|
+
const kept = value;
|
|
14
|
+
return (kept.way === "text" || kept.way === "photo") && typeof kept.text === "string" && Array.isArray(kept.ticked) && kept.ticked.every((index) => Number.isInteger(index)) && typeof kept.compared === "boolean";
|
|
15
|
+
};
|
|
16
|
+
/** The state of an open task as `ticked` of `total` points give it, and the points missed. */
|
|
17
|
+
function openTaskState(ticked, total) {
|
|
18
|
+
const missed = Array.from({ length: total }, (_point, index) => index).filter((index) => !ticked.includes(index));
|
|
19
|
+
return {
|
|
20
|
+
state: ticked.length === total ? "richtig" : ticked.length === 0 ? "falsch" : "teilweise",
|
|
21
|
+
missed
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Makes the open task in `element` live; returns what became of it so far. */
|
|
25
|
+
function startOpenTask(element, data, options = {}) {
|
|
26
|
+
const { lang, id, points } = data;
|
|
27
|
+
const t = (key, params) => escape(message(lang, key, params));
|
|
28
|
+
const live = element.querySelector(".explainer-open-live");
|
|
29
|
+
const listed = element.querySelector(".explainer-open-points");
|
|
30
|
+
const solution = element.querySelector(":scope > details.explainer-solution");
|
|
31
|
+
const empty = () => ({
|
|
32
|
+
state: "offen",
|
|
33
|
+
ticked: [],
|
|
34
|
+
missed: [],
|
|
35
|
+
photo: false
|
|
36
|
+
});
|
|
37
|
+
if (!live) return { result: empty };
|
|
38
|
+
if (listed) listed.hidden = true;
|
|
39
|
+
if (solution) solution.hidden = true;
|
|
40
|
+
const both = data.answer.includes("text") && data.answer.includes("photo");
|
|
41
|
+
let way = data.answer.includes("text") ? "text" : "photo";
|
|
42
|
+
let text = "";
|
|
43
|
+
let photo;
|
|
44
|
+
let ticked = [];
|
|
45
|
+
let compared = false;
|
|
46
|
+
const storage = options.assessment ? void 0 : createStorage(element.dataset["storage"] ?? "explainer", { type: "sessionStorage" });
|
|
47
|
+
const key = `antwort:${element.ownerDocument.location.pathname}#${id}`;
|
|
48
|
+
const kept = storage?.load(key, isKept);
|
|
49
|
+
if (kept !== void 0 && data.answer.includes(kept.way)) {
|
|
50
|
+
({way, text, compared} = kept);
|
|
51
|
+
ticked = kept.ticked.filter((index) => index < points.length);
|
|
52
|
+
if (way === "photo") compared = false;
|
|
53
|
+
}
|
|
54
|
+
const keep = () => storage?.save(key, {
|
|
55
|
+
way,
|
|
56
|
+
text,
|
|
57
|
+
ticked,
|
|
58
|
+
compared
|
|
59
|
+
});
|
|
60
|
+
live.innerHTML = [
|
|
61
|
+
`<div class="explainer-open-write">`,
|
|
62
|
+
both ? [
|
|
63
|
+
`<fieldset class="explainer-open-choice"><legend>${t("openTaskChoice")}</legend>`,
|
|
64
|
+
`<label><input type="radio" name="${id}-way" value="text" checked> <span>${t("openTaskType")}</span></label>`,
|
|
65
|
+
`<label><input type="radio" name="${id}-way" value="photo"> <span>${t("openTaskPaper")}</span></label>`,
|
|
66
|
+
`</fieldset>`
|
|
67
|
+
].join("") : "",
|
|
68
|
+
`<div class="explainer-open-text"><label for="${id}-text">${t("openTaskAnswer")}</label>`,
|
|
69
|
+
`<textarea id="${id}-text" rows="6"></textarea></div>`,
|
|
70
|
+
`<div class="explainer-open-photo" hidden><p>${t("openTaskPaperHow")}</p>`,
|
|
71
|
+
`<label class="explainer-open-shoot"><input type="file" accept="image/*" capture="environment"> <span>${t("openTaskPhoto")}</span></label>`,
|
|
72
|
+
`<img class="explainer-open-preview" alt="${t("openTaskPhotoAlt")}" hidden>`,
|
|
73
|
+
`<p class="explainer-open-local"><small>${t("openTaskPhotoLocal")}</small></p></div>`,
|
|
74
|
+
`<p class="explainer-open-need" aria-live="polite"></p>`,
|
|
75
|
+
`<p class="explainer-open-locked"><small>${t("openTaskLocked")}</small></p>`,
|
|
76
|
+
`</div>`,
|
|
77
|
+
`<fieldset class="explainer-open-check" hidden><legend>${t("openTaskCheck")}</legend>`,
|
|
78
|
+
`<p>${t("openTaskCheckHow")}</p>`,
|
|
79
|
+
points.map((point, index) => `<label><input type="checkbox" value="${index}"> <span>${escape(point.text)}${point.kind === "form" ? ` <small>(${t("openTaskForm")})</small>` : ""}${point.href === void 0 ? "" : ` <a href="${escape(point.href)}">${t("openTaskSource")}</a>`}</span></label>`).join(""),
|
|
80
|
+
`</fieldset>`,
|
|
81
|
+
`<p class="question-actions">`,
|
|
82
|
+
`<button type="button" class="explainer-open-compare">${t("openTaskCompare")}</button>`,
|
|
83
|
+
` <button type="button" class="explainer-open-change" hidden>${t("openTaskChange")}</button>`,
|
|
84
|
+
`</p>`
|
|
85
|
+
].join("");
|
|
86
|
+
live.hidden = false;
|
|
87
|
+
element.dispatchEvent(new CustomEvent(SHOWN_EVENT, { bubbles: true }));
|
|
88
|
+
const part = (selector) => live.querySelector(selector);
|
|
89
|
+
const write = part(".explainer-open-write");
|
|
90
|
+
const textPart = part(".explainer-open-text");
|
|
91
|
+
const photoPart = part(".explainer-open-photo");
|
|
92
|
+
const field = part("textarea");
|
|
93
|
+
const file = part("input[type=\"file\"]");
|
|
94
|
+
const preview = part(".explainer-open-preview");
|
|
95
|
+
const shoot = part(".explainer-open-shoot span");
|
|
96
|
+
const need = part(".explainer-open-need");
|
|
97
|
+
const check = part(".explainer-open-check");
|
|
98
|
+
const compare = part(".explainer-open-compare");
|
|
99
|
+
const change = part(".explainer-open-change");
|
|
100
|
+
const ready = () => way === "text" ? text.trim() !== "" : photo !== void 0;
|
|
101
|
+
const current = () => {
|
|
102
|
+
const answer = way === "text" ? { text: text.trim() } : {};
|
|
103
|
+
if (!compared) return {
|
|
104
|
+
...empty(),
|
|
105
|
+
...answer,
|
|
106
|
+
photo: way === "photo" && photo !== void 0
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
...openTaskState(ticked, points.length),
|
|
110
|
+
ticked: [...ticked],
|
|
111
|
+
...answer,
|
|
112
|
+
photo: way === "photo"
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
/** The step the learner is at: writing, or comparing. */
|
|
116
|
+
const show = () => {
|
|
117
|
+
if (textPart) textPart.hidden = way !== "text";
|
|
118
|
+
if (photoPart) photoPart.hidden = way !== "photo";
|
|
119
|
+
if (write) write.hidden = compared;
|
|
120
|
+
if (check) check.hidden = !compared;
|
|
121
|
+
if (compare) compare.hidden = compared;
|
|
122
|
+
if (solution) solution.hidden = !compared;
|
|
123
|
+
if (change) change.hidden = !compared;
|
|
124
|
+
if (need) need.textContent = compared || ready() ? "" : message(lang, way === "text" ? "openTaskWriteFirst" : "openTaskPhotoFirst");
|
|
125
|
+
};
|
|
126
|
+
for (const radio of live.querySelectorAll(".explainer-open-choice input")) radio.addEventListener("change", () => {
|
|
127
|
+
if (radio.checked) way = radio.value === "photo" ? "photo" : "text";
|
|
128
|
+
keep();
|
|
129
|
+
show();
|
|
130
|
+
});
|
|
131
|
+
field?.addEventListener("input", () => {
|
|
132
|
+
text = field.value;
|
|
133
|
+
keep();
|
|
134
|
+
show();
|
|
135
|
+
});
|
|
136
|
+
file?.addEventListener("change", () => {
|
|
137
|
+
const chosen = file.files?.[0];
|
|
138
|
+
if (!chosen) return;
|
|
139
|
+
if (photo !== void 0) URL.revokeObjectURL(photo);
|
|
140
|
+
photo = URL.createObjectURL(chosen);
|
|
141
|
+
if (preview) {
|
|
142
|
+
preview.src = photo;
|
|
143
|
+
preview.hidden = false;
|
|
144
|
+
}
|
|
145
|
+
if (shoot) shoot.textContent = message(lang, "openTaskPhotoAgain");
|
|
146
|
+
show();
|
|
147
|
+
});
|
|
148
|
+
compare?.addEventListener("click", () => {
|
|
149
|
+
if (!ready()) {
|
|
150
|
+
show();
|
|
151
|
+
if (need) moveFocus(need);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
compared = true;
|
|
155
|
+
keep();
|
|
156
|
+
show();
|
|
157
|
+
const legend = check?.querySelector("legend");
|
|
158
|
+
if (legend) moveFocus(legend);
|
|
159
|
+
options.onResult?.(current(), true);
|
|
160
|
+
});
|
|
161
|
+
change?.addEventListener("click", () => {
|
|
162
|
+
compared = false;
|
|
163
|
+
keep();
|
|
164
|
+
show();
|
|
165
|
+
if (field && way === "text") moveFocus(field);
|
|
166
|
+
});
|
|
167
|
+
for (const box of check?.querySelectorAll("input") ?? []) box.addEventListener("change", () => {
|
|
168
|
+
const index = Number(box.value);
|
|
169
|
+
ticked = box.checked ? [...ticked, index].sort((a, b) => a - b) : ticked.filter((one) => one !== index);
|
|
170
|
+
keep();
|
|
171
|
+
options.onResult?.(current(), false);
|
|
172
|
+
});
|
|
173
|
+
for (const radio of live.querySelectorAll(".explainer-open-choice input")) radio.checked = radio.value === way;
|
|
174
|
+
if (field) field.value = text;
|
|
175
|
+
for (const box of check?.querySelectorAll("input") ?? []) box.checked = ticked.includes(Number(box.value));
|
|
176
|
+
show();
|
|
177
|
+
return { result: current };
|
|
178
|
+
}
|
|
179
|
+
/** The names of the points at `indexes` (each from 0): P1, P2, … */
|
|
180
|
+
const pointNames = (indexes) => indexes.map((index) => `P${index + 1}`).join(", ");
|
|
181
|
+
// runtime/explainer/stuck.ts
|
|
182
|
+
/** The exercise and its task number, in `lang`: "prozentwert-ueben, Nr. 27". */
|
|
183
|
+
const taskOf = (stuck, lang) => ({
|
|
184
|
+
exercise: stuck.exercise,
|
|
185
|
+
number: stuck.number === void 0 ? "" : message(lang, "resultNumber", { n: String(stuck.number) })
|
|
186
|
+
});
|
|
187
|
+
/** What the learner did and was told, in `lang`. */
|
|
188
|
+
function stateLines(state, lang) {
|
|
189
|
+
const tries = state.attempts === 1 ? message(lang, "resultTry") : message(lang, "resultTries", { n: String(state.attempts) });
|
|
190
|
+
const lines = [];
|
|
191
|
+
if (state.rightAt !== void 0) lines.push(message(lang, "stuckRight", { n: String(state.rightAt) }));
|
|
192
|
+
else if (state.input !== void 0) lines.push(message(lang, "stuckInput", {
|
|
193
|
+
input: state.input,
|
|
194
|
+
tries
|
|
195
|
+
}));
|
|
196
|
+
else if (state.attempts > 0) lines.push(message(lang, "stuckTries", { tries }));
|
|
197
|
+
if (state.rightAt === void 0 && state.message !== void 0) lines.push(message(lang, "stuckMessage", { message: state.message.replace(/\s+/g, " ").trim() }));
|
|
198
|
+
if (state.hint === true) lines.push(message(lang, "stuckHint"));
|
|
199
|
+
if (state.revealed) lines.push(message(lang, "stuckSolution"));
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
/** The lines for a running chat (B2), in `lang`: the task, what the learner did and was told, the tutor page. */
|
|
203
|
+
const stuckLines = (stuck, lang) => [
|
|
204
|
+
message(lang, "stuckTask", taskOf(stuck, lang)),
|
|
205
|
+
...stateLines(stuck.state, lang),
|
|
206
|
+
`${message(lang, "resultTutor")} ${stuck.tutor}`
|
|
207
|
+
].join("\n");
|
|
208
|
+
/** The prompt of a new chat (B1), in `lang`: the tutor page and the task in one sentence, then the lines. */
|
|
209
|
+
const stuckPrompt = (stuck, lang) => [message(lang, "stuckAsk", {
|
|
210
|
+
url: stuck.tutor,
|
|
211
|
+
...taskOf(stuck, lang)
|
|
212
|
+
}), ...stateLines(stuck.state, lang)].join("\n");
|
|
213
|
+
// runtime/explainer/position.ts
|
|
214
|
+
/** The anchor of the address `href`, decoded. */
|
|
215
|
+
const anchorOf = (href) => decodeURIComponent(new URL(href).hash.slice(1));
|
|
216
|
+
/**
|
|
217
|
+
* The exercise the learner is at on the page at `href`: the one answered last, else the one the anchor names, else the
|
|
218
|
+
* page's only exercise; none on a page with several and no such hint.
|
|
219
|
+
*/
|
|
220
|
+
function currentExercise(href, page, last) {
|
|
221
|
+
const anchor = anchorOf(href);
|
|
222
|
+
const byId = (id) => page.exercises.find((exercise) => exercise.id === id);
|
|
223
|
+
return byId(last) ?? byId(anchor) ?? (page.exercises.length === 1 ? page.exercises[0] : void 0);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The query of the page's tutor page: the task number the address `href` holds of each exercise with numbers, in the
|
|
227
|
+
* page's order, then the exercise `exercise` and the section `section`, each if any; "" for none of them.
|
|
228
|
+
*/
|
|
229
|
+
function tutorQuery(href, page, exercise, section) {
|
|
230
|
+
const query = new URLSearchParams();
|
|
231
|
+
for (const candidate of page.exercises) {
|
|
232
|
+
const n = candidate.numbered ? numberFromAddress(href, candidate.id) : void 0;
|
|
233
|
+
if (n !== void 0) query.set(candidate.id, String(n));
|
|
234
|
+
}
|
|
235
|
+
if (exercise !== void 0) query.set("uebung", exercise.id);
|
|
236
|
+
if (section !== void 0) query.set("abschnitt", section);
|
|
237
|
+
const text = query.toString();
|
|
238
|
+
return text === "" ? "" : `?${text}`;
|
|
239
|
+
}
|
|
240
|
+
/** Way A: the task numbers the address `href` holds of the page's exercises, for learning the page. */
|
|
241
|
+
const pageQuery = (href, page) => tutorQuery(href, page, void 0, void 0);
|
|
242
|
+
/** Way B: the page's task numbers at `href`, and the exercise `id` with its section. */
|
|
243
|
+
function taskQuery(href, page, id) {
|
|
244
|
+
const exercise = page.exercises.find((candidate) => candidate.id === id);
|
|
245
|
+
return tutorQuery(href, page, exercise ?? {
|
|
246
|
+
id,
|
|
247
|
+
numbered: false
|
|
248
|
+
}, exercise?.section);
|
|
249
|
+
}
|
|
250
|
+
// runtime/explainer/assessment-client.ts
|
|
251
|
+
/** The value of copying the text in the select of the ways. */
|
|
252
|
+
const COPY$1 = "copy";
|
|
253
|
+
/** The words of a band in the result. */
|
|
254
|
+
const BAND = BAND_MESSAGES;
|
|
255
|
+
/** The offer on the start page or a chapter's page: shown, and naming the last result when the device keeps one. */
|
|
256
|
+
function startOffers(root) {
|
|
257
|
+
for (const offer of root.querySelectorAll(".explainer-assessment-offer")) {
|
|
258
|
+
const storage = createStorage(offer.dataset["storage"] ?? "explainer");
|
|
259
|
+
const link = offer.querySelector(".explainer-assessment-link");
|
|
260
|
+
const last = link?.dataset["last"];
|
|
261
|
+
if (link && last !== void 0 && storage.load(offer.dataset["key"] ?? "", isStoredResult) !== void 0) link.textContent = last;
|
|
262
|
+
offer.hidden = false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The options of a choice in an order of its own, new for each run, so that a second run asks the question, not the
|
|
267
|
+
* place of its answer.
|
|
268
|
+
*/
|
|
269
|
+
function mix(place, question) {
|
|
270
|
+
if (question.type !== "single-choice" && question.type !== "multiple-choice") return;
|
|
271
|
+
const options = [...place.querySelectorAll(".question-option")];
|
|
272
|
+
const parent = options[0]?.parentElement;
|
|
273
|
+
if (!parent) return;
|
|
274
|
+
for (const index of shuffled(options.length, Math.floor(Math.random() * 2 ** 31))) {
|
|
275
|
+
const option = options[index];
|
|
276
|
+
if (option) parent.append(option);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** A task number of 1 to `count`, at random, avoiding the numbers in `avoid` while others are left. */
|
|
280
|
+
function drawNumber(count, avoid) {
|
|
281
|
+
const left = Array.from({ length: count }, (_n, index) => index + 1).filter((n) => avoid.every((set) => !set.has(n)));
|
|
282
|
+
return left.length === 0 ? randomTaskNumber(count) : left[Math.floor(Math.random() * left.length)] ?? 1;
|
|
283
|
+
}
|
|
284
|
+
/** The task numbers of the round before, by exercise (its ref), from the address's vorher=a1.28,a4.2. */
|
|
285
|
+
function previousNumbers(href, items) {
|
|
286
|
+
const numbers = /* @__PURE__ */ new Map();
|
|
287
|
+
for (const pair of (new URL(href).searchParams.get("vorher") ?? "").split(",")) {
|
|
288
|
+
const [key, n] = pair.split(".");
|
|
289
|
+
const ref = items.find((item) => item.key === key)?.ref;
|
|
290
|
+
if (ref === void 0 || !Number.isInteger(Number(n))) continue;
|
|
291
|
+
numbers.set(ref, (numbers.get(ref) ?? /* @__PURE__ */ new Set()).add(Number(n)));
|
|
292
|
+
}
|
|
293
|
+
return numbers;
|
|
294
|
+
}
|
|
295
|
+
/** Puts what the build wrote for JavaScript into the page: each <template> of a self-assessment, in its place. */
|
|
296
|
+
function unpack(root) {
|
|
297
|
+
for (const template of root.querySelectorAll("template.explainer-assessment-template")) template.replaceWith(root.importNode(template.content, true));
|
|
298
|
+
root.dispatchEvent(new CustomEvent(ASSESSMENT_UNPACKED_EVENT));
|
|
299
|
+
}
|
|
300
|
+
/** Starts the self-assessment's page, and the offers on the start page and the chapters' pages. */
|
|
301
|
+
function startAssessment(root, ways) {
|
|
302
|
+
unpack(root);
|
|
303
|
+
startOffers(root);
|
|
304
|
+
const box = root.querySelector(".explainer-assessment");
|
|
305
|
+
const script = box?.querySelector("script.explainer-assessment-data");
|
|
306
|
+
if (!box || !script) return;
|
|
307
|
+
const data = JSON.parse(script.textContent ?? "{}");
|
|
308
|
+
const lang = root.documentElement.lang;
|
|
309
|
+
const t = (key, params) => message(lang, key, params);
|
|
310
|
+
const storage = ways.running ? void 0 : createStorage(data.storage);
|
|
311
|
+
const intro = box.querySelector(".explainer-assessment-intro");
|
|
312
|
+
const steps = [...box.querySelectorAll(".explainer-assessment-step")];
|
|
313
|
+
const result = box.querySelector(".explainer-assessment-result");
|
|
314
|
+
if (!intro || !result) return;
|
|
315
|
+
for (const nojs of root.querySelectorAll(".explainer-assessment-nojs")) nojs.hidden = true;
|
|
316
|
+
box.hidden = false;
|
|
317
|
+
box.addEventListener("submit", (event) => event.preventDefault());
|
|
318
|
+
const views = [
|
|
319
|
+
intro,
|
|
320
|
+
...steps,
|
|
321
|
+
result
|
|
322
|
+
];
|
|
323
|
+
const show = (view, focus) => {
|
|
324
|
+
for (const other of views) other.hidden = other !== view;
|
|
325
|
+
const heading = view.querySelector("h2");
|
|
326
|
+
if (focus && heading) moveFocus(heading);
|
|
327
|
+
};
|
|
328
|
+
const live = /* @__PURE__ */ new Map();
|
|
329
|
+
const answers = /* @__PURE__ */ new Map();
|
|
330
|
+
box.addEventListener(ASSESSMENT_ANSWER_EVENT, (event) => {
|
|
331
|
+
const key = event.target.closest(".explainer-assessment-item")?.dataset["key"];
|
|
332
|
+
const answer = event.detail;
|
|
333
|
+
if (key !== void 0) {
|
|
334
|
+
if (answer === null) answers.delete(key);
|
|
335
|
+
else answers.set(key, answer);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
const element = (key) => box.querySelector(`.explainer-assessment-item[data-key="${key}"]`);
|
|
339
|
+
const before = previousNumbers(root.location.href, data.items);
|
|
340
|
+
/** A new run: a new task of each generated exercise, every answer empty, the options mixed, the first page. */
|
|
341
|
+
const begin = () => {
|
|
342
|
+
live.clear();
|
|
343
|
+
answers.clear();
|
|
344
|
+
const drawn = /* @__PURE__ */ new Map();
|
|
345
|
+
for (const item of data.items) {
|
|
346
|
+
const place = element(item.key);
|
|
347
|
+
if (!place) continue;
|
|
348
|
+
const exercise = item.data;
|
|
349
|
+
if (exercise.kind === "question") {
|
|
350
|
+
place.querySelector("form")?.reset();
|
|
351
|
+
mix(place, exercise.question);
|
|
352
|
+
live.set(item.key, { question: exercise.question });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (exercise.kind === "open") {
|
|
356
|
+
const { result } = startOpenTask(place, {
|
|
357
|
+
...exercise,
|
|
358
|
+
id: item.key
|
|
359
|
+
}, { assessment: true });
|
|
360
|
+
live.set(item.key, { open: result });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const { seeds } = exercise;
|
|
364
|
+
const used = drawn.get(item.ref) ?? /* @__PURE__ */ new Set();
|
|
365
|
+
drawn.set(item.ref, used);
|
|
366
|
+
const number = drawNumber(Math.max(seeds.length, 1), [used, before.get(item.ref) ?? /* @__PURE__ */ new Set()]);
|
|
367
|
+
used.add(number);
|
|
368
|
+
place.dataset["number"] = String(number);
|
|
369
|
+
if (exercise.kind === "component") {
|
|
370
|
+
const component = place.querySelector(".explainer-component");
|
|
371
|
+
const seed = seeds[number - 1] ?? number;
|
|
372
|
+
if (component) {
|
|
373
|
+
component.dataset["seed"] = String(seed);
|
|
374
|
+
component.dispatchEvent(new CustomEvent(ASSESSMENT_TASK_EVENT, { detail: { seed } }));
|
|
375
|
+
}
|
|
376
|
+
live.set(item.key, {
|
|
377
|
+
component: true,
|
|
378
|
+
number
|
|
379
|
+
});
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const { spec, templates } = exercise;
|
|
383
|
+
const task = generate(spec, seeds[number - 1] ?? number, exercise.data);
|
|
384
|
+
const question = {
|
|
385
|
+
...questionFor(spec, task, lang, templates),
|
|
386
|
+
id: item.key
|
|
387
|
+
};
|
|
388
|
+
const target = place.querySelector(".explainer-assessment-live");
|
|
389
|
+
if (target) target.innerHTML = renderQuestion(question, {
|
|
390
|
+
lang,
|
|
391
|
+
textHtml: taskText(spec, task, lang, templates)
|
|
392
|
+
});
|
|
393
|
+
mix(place, question);
|
|
394
|
+
live.set(item.key, {
|
|
395
|
+
question,
|
|
396
|
+
number,
|
|
397
|
+
mistake: (answer) => mistakeOf(spec, task, question, answer)
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
const first = steps[0];
|
|
401
|
+
if (first) show(first, true);
|
|
402
|
+
};
|
|
403
|
+
/** What became of each exercise of the run. */
|
|
404
|
+
const evaluate = () => data.items.map((item) => {
|
|
405
|
+
const run = live.get(item.key);
|
|
406
|
+
const form = element(item.key)?.querySelector("form");
|
|
407
|
+
const base = {
|
|
408
|
+
key: item.key,
|
|
409
|
+
ref: item.ref,
|
|
410
|
+
...run?.number === void 0 ? {} : { number: run.number }
|
|
411
|
+
};
|
|
412
|
+
if (run?.open !== void 0) {
|
|
413
|
+
const answer = run.open();
|
|
414
|
+
if (answer.state === "offen") return {
|
|
415
|
+
...base,
|
|
416
|
+
state: "offen"
|
|
417
|
+
};
|
|
418
|
+
return {
|
|
419
|
+
...base,
|
|
420
|
+
state: answer.state,
|
|
421
|
+
...answer.text === void 0 || answer.text === "" ? {} : { input: answer.text },
|
|
422
|
+
...answer.missed.length === 0 ? {} : { missed: answer.missed.map((point) => point + 1) }
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
if (run?.component === true) {
|
|
426
|
+
const answer = answers.get(item.key);
|
|
427
|
+
if (answer === void 0) return {
|
|
428
|
+
...base,
|
|
429
|
+
state: "offen"
|
|
430
|
+
};
|
|
431
|
+
if (answer.correct) return {
|
|
432
|
+
...base,
|
|
433
|
+
state: "richtig"
|
|
434
|
+
};
|
|
435
|
+
return {
|
|
436
|
+
...base,
|
|
437
|
+
state: answer.partly === true ? "teilweise" : "falsch",
|
|
438
|
+
...answer.answer === void 0 ? {} : { input: answer.answer },
|
|
439
|
+
...answer.mistake === void 0 || answer.partly === true ? {} : { mistake: answer.mistake + 1 },
|
|
440
|
+
...answer.missed === void 0 ? {} : { missed: answer.missed.map((point) => point + 1) }
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (!run?.question || !form) return {
|
|
444
|
+
...base,
|
|
445
|
+
state: "offen"
|
|
446
|
+
};
|
|
447
|
+
const answer = readAnswer(run.question, form);
|
|
448
|
+
if (!isComplete(run.question, answer)) return {
|
|
449
|
+
...base,
|
|
450
|
+
state: "offen"
|
|
451
|
+
};
|
|
452
|
+
if (isCorrect(run.question, answer)) return {
|
|
453
|
+
...base,
|
|
454
|
+
state: "richtig"
|
|
455
|
+
};
|
|
456
|
+
const mistake = run.mistake?.(answer);
|
|
457
|
+
return {
|
|
458
|
+
...base,
|
|
459
|
+
state: "falsch",
|
|
460
|
+
input: answerShown(run.question, answer, lang),
|
|
461
|
+
...mistake === void 0 ? {} : { mistake: mistake + 1 }
|
|
462
|
+
};
|
|
463
|
+
});
|
|
464
|
+
const score = result.querySelector(".explainer-assessment-score");
|
|
465
|
+
const advice = result.querySelector(".explainer-assessment-advice");
|
|
466
|
+
const list = result.querySelector(".explainer-assessment-pages");
|
|
467
|
+
const status = result.querySelector(".explainer-assessment-status");
|
|
468
|
+
let shown = [];
|
|
469
|
+
/** Shows the result of a run: the score, the advice, and each content page with its band. */
|
|
470
|
+
const showResult = (results, focus) => {
|
|
471
|
+
shown = results;
|
|
472
|
+
const pageOf = Object.fromEntries(data.items.map((item) => [item.key, item.page]));
|
|
473
|
+
const pages = pageResults(data.pages.map((page) => page.page), pageOf, results);
|
|
474
|
+
const right = results.filter((one) => one.state === "richtig").length;
|
|
475
|
+
const partly = results.filter((one) => one.state === "teilweise").length;
|
|
476
|
+
if (score) score.textContent = t(partly > 0 ? "assessmentScorePartly" : "assessmentScore", {
|
|
477
|
+
right: String(right),
|
|
478
|
+
partly: String(partly),
|
|
479
|
+
total: String(results.length)
|
|
480
|
+
});
|
|
481
|
+
if (advice) {
|
|
482
|
+
const open = pages.some((page) => page.band === "read" || page.band === "partly");
|
|
483
|
+
advice.textContent = t(open ? "assessmentAdvice" : "assessmentAllKnown");
|
|
484
|
+
}
|
|
485
|
+
if (list) list.replaceChildren(...pages.map((page) => {
|
|
486
|
+
const info = data.pages.find((candidate) => candidate.page === page.page);
|
|
487
|
+
const item = root.createElement("li");
|
|
488
|
+
item.className = "explainer-assessment-page";
|
|
489
|
+
item.dataset["band"] = page.band;
|
|
490
|
+
const link = root.createElement("a");
|
|
491
|
+
link.href = info?.href ?? "";
|
|
492
|
+
link.textContent = info?.title ?? page.page;
|
|
493
|
+
const band = root.createElement("strong");
|
|
494
|
+
band.textContent = t(BAND[page.band]);
|
|
495
|
+
item.append(link, " ", band);
|
|
496
|
+
if (page.total > 0) {
|
|
497
|
+
const count = root.createElement("span");
|
|
498
|
+
count.className = "explainer-assessment-count";
|
|
499
|
+
count.textContent = t(page.partly > 0 ? "assessmentPageScorePartly" : "assessmentPageScore", {
|
|
500
|
+
right: String(page.right),
|
|
501
|
+
partly: String(page.partly),
|
|
502
|
+
total: String(page.total)
|
|
503
|
+
});
|
|
504
|
+
item.append(" ", count);
|
|
505
|
+
}
|
|
506
|
+
return item;
|
|
507
|
+
}));
|
|
508
|
+
if (status) status.textContent = "";
|
|
509
|
+
show(result, focus);
|
|
510
|
+
};
|
|
511
|
+
const links = () => linkLines(new URL(data.root, root.location.href).href, data.chapters, data.whole, lang);
|
|
512
|
+
const promptItems = () => data.items.map((item) => ({
|
|
513
|
+
key: item.key,
|
|
514
|
+
page: data.pages.find((page) => page.page === item.page)?.title ?? item.page,
|
|
515
|
+
exercise: item.exercise
|
|
516
|
+
}));
|
|
517
|
+
const promptPages = () => pageResults(data.pages.map((page) => page.page), Object.fromEntries(data.items.map((item) => [item.key, item.page])), shown).map((page) => ({
|
|
518
|
+
...page,
|
|
519
|
+
title: data.pages.find((one) => one.page === page.page)?.title ?? page.page
|
|
520
|
+
}));
|
|
521
|
+
const select = result.querySelector(".explainer-assessment-way");
|
|
522
|
+
const go = result.querySelector(".explainer-assessment-go");
|
|
523
|
+
const said = result.querySelector(".explainer-assessment-tutor-status");
|
|
524
|
+
const hint = result.querySelector(".explainer-tutor-hint");
|
|
525
|
+
const tutorBox = result.querySelector(".explainer-assessment-tutor");
|
|
526
|
+
if (ways.running && tutorBox) {
|
|
527
|
+
const back = root.createElement("button");
|
|
528
|
+
back.type = "button";
|
|
529
|
+
back.className = "explainer-assessment-back-to-tutor";
|
|
530
|
+
back.textContent = t("backToTutor");
|
|
531
|
+
const intro = root.createElement("p");
|
|
532
|
+
intro.textContent = t("assessmentBackIntro");
|
|
533
|
+
const row = root.createElement("p");
|
|
534
|
+
row.append(back);
|
|
535
|
+
tutorBox.replaceChildren(intro, row);
|
|
536
|
+
back.addEventListener("click", () => {
|
|
537
|
+
const base = new URL(data.root, root.location.href);
|
|
538
|
+
const tutor = new URL(`${data.tutor}${resultQuery(shown)}`, base).href;
|
|
539
|
+
const next = new URL(root.location.href);
|
|
540
|
+
next.search = "";
|
|
541
|
+
next.hash = "";
|
|
542
|
+
next.searchParams.set("via", "tutor");
|
|
543
|
+
const numbers = shown.flatMap((one) => one.number === void 0 ? [] : [`${one.key}.${one.number}`]);
|
|
544
|
+
if (numbers.length > 0) next.searchParams.set("vorher", numbers.join(","));
|
|
545
|
+
const lines = assessmentPrompt(tutor, promptItems(), shown, lang, promptPages(), "back", links());
|
|
546
|
+
ways.back(`${lines}\n${t("assessmentNextRound", { url: next.href.replace(/%2C/g, ",") })}`, back, tutorBox, data.storage, data.ways);
|
|
547
|
+
});
|
|
548
|
+
result.querySelector(".explainer-assessment-delete")?.remove();
|
|
549
|
+
const privacy = root.querySelector(".explainer-assessment-privacy");
|
|
550
|
+
if (privacy) privacy.textContent = t("assessmentTutorRun");
|
|
551
|
+
} else if (select && go) {
|
|
552
|
+
const chosen = ways.chosen(data.storage, data.ways);
|
|
553
|
+
if (chosen !== void 0) select.value = chosen.url === void 0 ? COPY$1 : chosen.name;
|
|
554
|
+
const way = () => select.value === COPY$1 ? { name: COPY$1 } : data.ways.find((candidate) => candidate.name === select.value);
|
|
555
|
+
const label = () => {
|
|
556
|
+
const current = way();
|
|
557
|
+
go.textContent = current?.url === void 0 ? t("tutorCopy") : t("assessmentTutorGo", { name: current.name });
|
|
558
|
+
ways.hint(select, hint);
|
|
559
|
+
};
|
|
560
|
+
select.addEventListener("change", label);
|
|
561
|
+
label();
|
|
562
|
+
go.addEventListener("click", () => {
|
|
563
|
+
const href = root.location.href;
|
|
564
|
+
const base = new URL(data.root, href);
|
|
565
|
+
const needs = ways.needs(data.storage);
|
|
566
|
+
const tutor = new URL(`${data.tutor}${withNeeds(resultQuery(shown), needs)}`, base).href;
|
|
567
|
+
const prompt = () => withLanguage(assessmentPrompt(tutor, promptItems(), shown, lang, promptPages(), "ask", links()), needs, lang);
|
|
568
|
+
const copy = async () => {
|
|
569
|
+
try {
|
|
570
|
+
const response = await fetch(new URL(data.markdown, base).href);
|
|
571
|
+
if (response.ok) return `${prompt()}\n\n${(await response.text()).trimEnd()}\n`;
|
|
572
|
+
} catch {}
|
|
573
|
+
return prompt();
|
|
574
|
+
};
|
|
575
|
+
ways.open(way(), {
|
|
576
|
+
start: prompt,
|
|
577
|
+
copy,
|
|
578
|
+
placeholder: data.placeholder
|
|
579
|
+
}, (copied) => {
|
|
580
|
+
if (said) said.textContent = copied === void 0 ? "" : t(copied ? "tutorCopied" : "tutorCopyFailed");
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
box.querySelector(".explainer-assessment-begin")?.addEventListener("click", begin);
|
|
585
|
+
steps.forEach((step, index) => {
|
|
586
|
+
step.querySelector(".explainer-assessment-back")?.addEventListener("click", () => {
|
|
587
|
+
const before = steps[index - 1];
|
|
588
|
+
if (before) show(before, true);
|
|
589
|
+
});
|
|
590
|
+
step.querySelector(".explainer-assessment-next")?.addEventListener("click", () => {
|
|
591
|
+
const after = steps[index + 1];
|
|
592
|
+
if (after) show(after, true);
|
|
593
|
+
});
|
|
594
|
+
step.querySelector(".explainer-assessment-finish")?.addEventListener("click", () => {
|
|
595
|
+
const results = evaluate();
|
|
596
|
+
storage?.save(data.key, {
|
|
597
|
+
version: 1,
|
|
598
|
+
items: results
|
|
599
|
+
});
|
|
600
|
+
showResult(results, true);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
result.querySelector(".explainer-assessment-restart")?.addEventListener("click", begin);
|
|
604
|
+
result.querySelector(".explainer-assessment-delete")?.addEventListener("click", () => {
|
|
605
|
+
storage?.remove(data.key);
|
|
606
|
+
show(intro, false);
|
|
607
|
+
const button = intro.querySelector(".explainer-assessment-begin");
|
|
608
|
+
if (button) button.focus();
|
|
609
|
+
const line = root.createElement("p");
|
|
610
|
+
line.className = "explainer-assessment-deleted";
|
|
611
|
+
line.setAttribute("role", "status");
|
|
612
|
+
line.textContent = t("assessmentDeleted");
|
|
613
|
+
intro.querySelector(".explainer-assessment-deleted")?.remove();
|
|
614
|
+
intro.prepend(line);
|
|
615
|
+
});
|
|
616
|
+
const kept = (storage?.load(data.key, isStoredResult))?.items.filter((one) => data.items.some((item) => item.key === one.key && item.ref === one.ref));
|
|
617
|
+
if (kept !== void 0 && kept.length > 0) showResult(kept, false);
|
|
618
|
+
else show(intro, false);
|
|
619
|
+
}
|
|
620
|
+
// runtime/explainer/explainer.ts
|
|
621
|
+
/** Makes a generated exercise live: a task from a seed, checked by the shared questions, and "Neue Aufgabe". */
|
|
622
|
+
function startGenerated(element, data, progress) {
|
|
623
|
+
const live = element.querySelector(".explainer-live");
|
|
624
|
+
if (!live) return;
|
|
625
|
+
const { spec, templates, lang, texts } = data;
|
|
626
|
+
for (const part of element.querySelectorAll(".explainer-worked-intro, .explainer-worked")) part.hidden = true;
|
|
627
|
+
const { seeds } = data;
|
|
628
|
+
const count = Math.max(seeds.length, 1);
|
|
629
|
+
const from = numberFromAddress(location.href, spec.id);
|
|
630
|
+
let number = from === void 0 ? randomTaskNumber(count) : taskNumber(from, count);
|
|
631
|
+
history.replaceState(history.state, "", addressOf(location.href, spec.id, number));
|
|
632
|
+
const show = (focus) => {
|
|
633
|
+
const task = generate(spec, seeds[number - 1] ?? number, data.data);
|
|
634
|
+
const question = questionFor(spec, task, lang, templates);
|
|
635
|
+
const hint = hintOf(spec, task, lang, templates);
|
|
636
|
+
const steps = stepsOf(spec, task, lang, templates);
|
|
637
|
+
live.innerHTML = [
|
|
638
|
+
`<p class="explainer-practice">${escape(texts.practice)}</p>`,
|
|
639
|
+
`<p class="explainer-task-number">${escape(texts.taskNumber.replace("{n}", String(number)))} <button type="button" class="explainer-new-task" aria-label="${escape(texts.newTask)}" title="${escape(texts.newTask)}">${NEW_TASK_ICON}</button></p>`,
|
|
640
|
+
renderQuestion(question, {
|
|
641
|
+
lang,
|
|
642
|
+
textHtml: taskText(spec, task, lang, templates),
|
|
643
|
+
...steps.length === 0 ? {} : { explanationHtml: `<ol class="explainer-steps">${steps.map((step) => `<li>${step}</li>`).join("")}</ol>` }
|
|
644
|
+
}),
|
|
645
|
+
hint === void 0 ? "" : `<details class="explainer-hint"><summary>${escape(texts.hint)}</summary><p>${hint}</p></details>`
|
|
646
|
+
].join("\n");
|
|
647
|
+
live.hidden = false;
|
|
648
|
+
const item = live.querySelector(`#${questionId(question)}`);
|
|
649
|
+
if (item) {
|
|
650
|
+
const shown = number;
|
|
651
|
+
startQuestion(item, question, {
|
|
652
|
+
lang,
|
|
653
|
+
retry: true,
|
|
654
|
+
diagnose: (answer) => diagnose(spec, task, question, answer, lang, templates),
|
|
655
|
+
onAnswer: (answer, right, said) => progress.answered(spec.id, shown, right, answerShown(question, answer, lang), said),
|
|
656
|
+
onReveal: () => progress.revealed(spec.id, shown)
|
|
657
|
+
});
|
|
658
|
+
const opened = live.querySelector("details.explainer-hint");
|
|
659
|
+
opened?.addEventListener("toggle", () => {
|
|
660
|
+
if (opened.open) progress.hinted(spec.id, shown);
|
|
661
|
+
});
|
|
662
|
+
const legend = item.querySelector("legend");
|
|
663
|
+
if (focus && legend) moveFocus(legend);
|
|
664
|
+
}
|
|
665
|
+
element.dispatchEvent(new CustomEvent(SHOWN_EVENT, { bubbles: true }));
|
|
666
|
+
live.querySelector(".explainer-new-task")?.addEventListener("click", () => {
|
|
667
|
+
number = nextTaskNumber(number, count);
|
|
668
|
+
history.replaceState(history.state, "", addressOf(location.href, spec.id, number));
|
|
669
|
+
show(true);
|
|
670
|
+
});
|
|
671
|
+
};
|
|
672
|
+
show(false);
|
|
673
|
+
}
|
|
674
|
+
/** Whether a tutor opened the page: via=tutor in its address, exactly so. */
|
|
675
|
+
const fromTutor = (href) => new URL(href).searchParams.get("via") === "tutor";
|
|
676
|
+
/** The event an island sends for an exercise component's answer (islands.ts), with { correct, answer?, message? }. */
|
|
677
|
+
const ANSWERED_EVENT = "explainer-answered";
|
|
678
|
+
/** How long the button says "kopiert" after a copy. */
|
|
679
|
+
const DONE_MS = 8e3;
|
|
680
|
+
/** The data of the page's tutor section, if it has one. */
|
|
681
|
+
function tutorData(root) {
|
|
682
|
+
const script = root.querySelector(".explainer-tutor script.explainer-tutor-prompt");
|
|
683
|
+
return script ? JSON.parse(script.textContent ?? "{}") : void 0;
|
|
684
|
+
}
|
|
685
|
+
/** Whether `text` is now on the clipboard. */
|
|
686
|
+
async function copyText(text) {
|
|
687
|
+
try {
|
|
688
|
+
await navigator.clipboard.writeText(text);
|
|
689
|
+
return true;
|
|
690
|
+
} catch {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/** The absolute address of the page's tutor page with `query`, from the page at `href`. */
|
|
695
|
+
const tutorAddress = (href, data, query) => new URL(`${data.tutor ?? ""}${query}`, new URL(data.root, href)).href;
|
|
696
|
+
/**
|
|
697
|
+
* `prompt` with the Markdown of the page's tutor page after it, for an assistant that fetches nothing: the app's own
|
|
698
|
+
* static file; the prompt alone when it is not reachable, as it names the tutor page.
|
|
699
|
+
*/
|
|
700
|
+
async function withMarkdown(prompt, data, href) {
|
|
701
|
+
if (data.markdown === void 0) return prompt;
|
|
702
|
+
try {
|
|
703
|
+
const response = await fetch(new URL(data.markdown, new URL(data.root, href)).href);
|
|
704
|
+
if (response.ok) return `${prompt}\n\n${(await response.text()).trimEnd()}\n`;
|
|
705
|
+
} catch {}
|
|
706
|
+
return prompt;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* The result line of the page at `href` (result.ts): of the exercise `id`, else of the one the learner answered last,
|
|
710
|
+
* else the one the anchor names, else the page's first; with the absolute address of the page's tutor page with the
|
|
711
|
+
* position.
|
|
712
|
+
*/
|
|
713
|
+
function resultOf(href, lang, data, progress, id) {
|
|
714
|
+
const { page, tutor } = data;
|
|
715
|
+
const exercise = page === void 0 ? void 0 : page.exercises.find((candidate) => candidate.id === id) ?? currentExercise(href, page, progress.last) ?? page.exercises[0];
|
|
716
|
+
if (page === void 0 || exercise === void 0 || tutor === void 0) return void 0;
|
|
717
|
+
const n = exercise.numbered ? numberFromAddress(href, exercise.id) : void 0;
|
|
718
|
+
return resultLine({
|
|
719
|
+
page: page.title,
|
|
720
|
+
exercise: exercise.id,
|
|
721
|
+
...n === void 0 ? {} : { number: n },
|
|
722
|
+
state: progress.state(exercise.id, n),
|
|
723
|
+
session: progress.session(),
|
|
724
|
+
tutor: tutorAddress(href, data, taskQuery(href, page, exercise.id))
|
|
725
|
+
}, lang);
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Copies `text` for the running chat with the tutor ("Zurück zum Tutor", "Hilfe" and "Zurück" on a page a tutor
|
|
729
|
+
* opened, via=tutor): the tab stays open, as closing it let the browser show some other tab; `button` says "✓ Kopiert"
|
|
730
|
+
* for a while, as the tutor bar does after copying, and `status`, on a line below it, where to paste it. When
|
|
731
|
+
* copying fails, the text is selected in `field` to copy by hand. The text goes to the clipboard only, never over the
|
|
732
|
+
* network.
|
|
733
|
+
*/
|
|
734
|
+
function copyForChat(root, text, button, status, field, texts) {
|
|
735
|
+
const lang = root.documentElement.lang;
|
|
736
|
+
status.textContent = "";
|
|
737
|
+
copyText(text).then((copied) => {
|
|
738
|
+
if (!copied) {
|
|
739
|
+
field.value = text;
|
|
740
|
+
field.hidden = false;
|
|
741
|
+
field.focus();
|
|
742
|
+
field.select();
|
|
743
|
+
status.textContent = texts.failed;
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
field.hidden = true;
|
|
747
|
+
status.textContent = texts.copied;
|
|
748
|
+
const before = [...button.childNodes];
|
|
749
|
+
button.dataset["done"] = "";
|
|
750
|
+
button.textContent = message(lang, "backToTutorDone");
|
|
751
|
+
setTimeout(() => {
|
|
752
|
+
if (button.dataset["done"] === void 0) return;
|
|
753
|
+
delete button.dataset["done"];
|
|
754
|
+
button.replaceChildren(...before);
|
|
755
|
+
}, DONE_MS);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
/** A status line and the field for copying by hand, hidden until copying fails. */
|
|
759
|
+
function statusAndField(root, label) {
|
|
760
|
+
const status = root.createElement("span");
|
|
761
|
+
status.setAttribute("role", "status");
|
|
762
|
+
status.setAttribute("aria-live", "polite");
|
|
763
|
+
const field = root.createElement("textarea");
|
|
764
|
+
field.className = "explainer-back-line";
|
|
765
|
+
field.readOnly = true;
|
|
766
|
+
field.rows = 3;
|
|
767
|
+
field.hidden = true;
|
|
768
|
+
field.setAttribute("aria-label", label);
|
|
769
|
+
return {
|
|
770
|
+
status,
|
|
771
|
+
field
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* What "Zurück zum Tutor" copies on the page at `href`: the page's tutor page with the page's task numbers, and each
|
|
776
|
+
* task the learner did in this session with how it went (result.ts, sessionLines).
|
|
777
|
+
*/
|
|
778
|
+
function sessionOf(href, lang, data, progress) {
|
|
779
|
+
const { page, tutor } = data;
|
|
780
|
+
if (page === void 0 || tutor === void 0) return void 0;
|
|
781
|
+
return sessionLines(tutorAddress(href, data, pageQuery(href, page)), progress.tasks(), progress.session(), lang);
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* "Zurück zum Tutor" at the top and at the end of a page a tutor opened (via=tutor): it copies the page's tutor page
|
|
785
|
+
* and how each task of the session went, for the running chat (copyForChat).
|
|
786
|
+
*/
|
|
787
|
+
function startBackToTutor(root, main, data, progress) {
|
|
788
|
+
const lang = root.documentElement.lang;
|
|
789
|
+
for (const at of ["top", "end"]) {
|
|
790
|
+
const box = root.createElement("p");
|
|
791
|
+
box.className = `explainer-back explainer-back-${at}`;
|
|
792
|
+
const button = root.createElement("button");
|
|
793
|
+
button.type = "button";
|
|
794
|
+
button.textContent = message(lang, "backToTutor");
|
|
795
|
+
const { status, field } = statusAndField(root, message(lang, "resultLabel"));
|
|
796
|
+
button.addEventListener("click", () => {
|
|
797
|
+
const lines = data === void 0 ? void 0 : sessionOf(root.location.href, lang, data, progress);
|
|
798
|
+
const name = chatName(data, lang);
|
|
799
|
+
if (lines === void 0) {
|
|
800
|
+
status.textContent = message(lang, "backToTutorOpen", { name });
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
copyForChat(root, lines, button, status, field, {
|
|
804
|
+
copied: message(lang, "backToTutorCopied", { name }),
|
|
805
|
+
failed: message(lang, "backToTutorFailed", { name })
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
box.append(button, " ", status, field);
|
|
809
|
+
if (at === "top") main.prepend(box);
|
|
810
|
+
else main.append(box);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Opens the assistant `provider` with the start prompt, when its address takes one; else copies the prompt to copy and
|
|
815
|
+
* opens the assistant (none for copying the text), and `say` tells whether copying worked. A way that needs pasting
|
|
816
|
+
* copies only the start prompt, at once, and its address gets only the placeholder message. The window opens at the click, which a browser
|
|
817
|
+
* allows; the address follows once the prompt is copied.
|
|
818
|
+
*/
|
|
819
|
+
function openAssistant(root, provider, prompts, say) {
|
|
820
|
+
const template = provider?.url;
|
|
821
|
+
const paste = provider?.paste === true;
|
|
822
|
+
if (template !== void 0 && template.includes("{prompt}") && !paste) {
|
|
823
|
+
say(void 0);
|
|
824
|
+
root.defaultView?.open(template.split("{prompt}").join(encodeURIComponent(prompts.start())), "_blank", "noopener");
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const copied = paste ? copyText(prompts.start()) : void 0;
|
|
828
|
+
const opened = template === void 0 ? null : root.defaultView?.open("", "_blank");
|
|
829
|
+
if (opened) opened.opener = null;
|
|
830
|
+
Promise.resolve(copied ?? prompts.copy()).then(async (full) => {
|
|
831
|
+
say(typeof full === "boolean" ? full : await copyText(full));
|
|
832
|
+
if (template === void 0) return;
|
|
833
|
+
const address = paste ? template.split("{prompt}").join(encodeURIComponent(prompts.placeholder)) : template;
|
|
834
|
+
if (opened) opened.location.href = address;
|
|
835
|
+
else root.defaultView?.open(address, "_blank", "noopener");
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
/** The key the switched-on way to the tutor is kept under on the device: an assistant's name, or COPY; none when off. */
|
|
839
|
+
const WAY_KEY = "tutor-way";
|
|
840
|
+
const COPY = "copy";
|
|
841
|
+
const isText = (value) => typeof value === "string";
|
|
842
|
+
/**
|
|
843
|
+
* The way to the tutor the learner switched on, kept on the device (@lernapps/tooling/storage), one per app: on the
|
|
844
|
+
* start page or under the title of a page. Whoever shows something of it listens, so that switching it on or off shows
|
|
845
|
+
* at once on the whole page.
|
|
846
|
+
*/
|
|
847
|
+
var WayChoice = class {
|
|
848
|
+
#storage;
|
|
849
|
+
#ways;
|
|
850
|
+
#listeners = [];
|
|
851
|
+
constructor(storage, ways) {
|
|
852
|
+
this.#storage = createStorage(storage);
|
|
853
|
+
this.#ways = ways;
|
|
854
|
+
}
|
|
855
|
+
/** The way switched on, if any: an assistant the app still offers, or copying. */
|
|
856
|
+
get() {
|
|
857
|
+
const name = this.#storage.load(WAY_KEY, isText);
|
|
858
|
+
if (name === COPY) return { name: COPY };
|
|
859
|
+
return this.#ways.find((way) => way.name === name);
|
|
860
|
+
}
|
|
861
|
+
/** Switches the way `name` on (an assistant's name, or COPY), or the tutor off. */
|
|
862
|
+
set(name) {
|
|
863
|
+
if (name === void 0) this.#storage.remove(WAY_KEY);
|
|
864
|
+
else this.#storage.save(WAY_KEY, name);
|
|
865
|
+
for (const listener of this.#listeners) listener();
|
|
866
|
+
}
|
|
867
|
+
listen(listener) {
|
|
868
|
+
this.#listeners.push(listener);
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
/** The archetype whose settings the needs are: they hold for every explainer, or for one app (dec-tutor-needs). */
|
|
872
|
+
const ARCHETYPE = "explainer";
|
|
873
|
+
/** The needs the learner chose for the tutor (needs.ts), for this app or for every explainer, if any. */
|
|
874
|
+
const chosenNeeds = (storage) => createSettings(storage, ARCHETYPE).load(NEEDS_KEY, isNeedsChoice)?.value;
|
|
875
|
+
/**
|
|
876
|
+
* The name of the running chat's assistant, as "zurück zu …" says it: the one the learner switched on, else "deinem
|
|
877
|
+
* KI-Tutor".
|
|
878
|
+
*/
|
|
879
|
+
function chatName(data, lang) {
|
|
880
|
+
const way = data === void 0 ? void 0 : new WayChoice(data.storage, data.ways).get();
|
|
881
|
+
return way?.url === void 0 ? message(lang, "tutorSomeoneTo") : way.name;
|
|
882
|
+
}
|
|
883
|
+
/** The name of a way as the page says it: the assistant's, or "deinen KI-Tutor" for copying. */
|
|
884
|
+
const nameOf = (way, lang) => way?.url === void 0 ? message(lang, "tutorSomeone") : way.name;
|
|
885
|
+
/** The symbol of a way: the first letter of the assistant's name in a circle, "?" for copying. */
|
|
886
|
+
function iconOf(root, way) {
|
|
887
|
+
const icon = root.createElement("span");
|
|
888
|
+
icon.className = "explainer-ki-icon";
|
|
889
|
+
icon.setAttribute("aria-hidden", "true");
|
|
890
|
+
icon.textContent = way?.url === void 0 ? "?" : (way.name.trim()[0] ?? "?").toUpperCase();
|
|
891
|
+
return icon;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* The tutor's help button in every exercise of the page, way B, there only while a way to the tutor is switched on or
|
|
895
|
+
* on a page a tutor opened (via=tutor). It sits in the row of "Prüfen" and "Lösung" (or below an exercise component,
|
|
896
|
+
* outside its island),
|
|
897
|
+
* with the assistant's symbol, and says what helps now: "Hilfe" before an answer and after the right one; "Warum?",
|
|
898
|
+
* standing out, after a wrong one, with a sentence that says what the click gives the assistant; on a page a tutor
|
|
899
|
+
* opened, "Zurück" once the task is right or its solution shown. "Hilfe" and "Warum?" give the lines of the task as the
|
|
900
|
+
* learner has it now (stuck.ts): the task, the input with the tries, the app's message word for word, the hint or
|
|
901
|
+
* solution opened, the page's tutor page with the task (taskQuery). B1, without a chat: they open the way switched
|
|
902
|
+
* on, the tutor page and the task in one sentence first. B2, on a page a tutor opened: they copy the lines, and the
|
|
903
|
+
* button says where to paste them (copyForChat), so the running chat goes on; "Zurück" copies the result line of the
|
|
904
|
+
* task the same way.
|
|
905
|
+
*/
|
|
906
|
+
function startHelp(root, data, progress, choice) {
|
|
907
|
+
const { page, tutor } = data;
|
|
908
|
+
if (page === void 0 || tutor === void 0) return;
|
|
909
|
+
const lang = root.documentElement.lang;
|
|
910
|
+
const running = fromTutor(root.location.href);
|
|
911
|
+
const updates = /* @__PURE__ */ new Map();
|
|
912
|
+
for (const exercise of page.exercises) {
|
|
913
|
+
const element = root.getElementById(exercise.id);
|
|
914
|
+
if (!element) continue;
|
|
915
|
+
const button = root.createElement("button");
|
|
916
|
+
button.type = "button";
|
|
917
|
+
button.className = "explainer-help";
|
|
918
|
+
const label = root.createElement("span");
|
|
919
|
+
const own = root.createElement("p");
|
|
920
|
+
own.className = "explainer-help-row";
|
|
921
|
+
const note = root.createElement("p");
|
|
922
|
+
note.className = "explainer-help-note";
|
|
923
|
+
const text = root.createElement("small");
|
|
924
|
+
const { status, field } = statusAndField(root, message(lang, "stuckLabel"));
|
|
925
|
+
note.append(text, " ", status, field);
|
|
926
|
+
const place = () => {
|
|
927
|
+
const row = element.classList.contains("explainer-component") ? element.querySelector(":scope > form.explainer-component-form > .question-actions") : element.querySelector(".question-actions");
|
|
928
|
+
if (row) {
|
|
929
|
+
row.append(" ", button);
|
|
930
|
+
own.remove();
|
|
931
|
+
} else {
|
|
932
|
+
own.append(button);
|
|
933
|
+
element.append(own);
|
|
934
|
+
}
|
|
935
|
+
element.append(note);
|
|
936
|
+
};
|
|
937
|
+
const n = () => exercise.numbered ? numberFromAddress(root.location.href, exercise.id) : void 0;
|
|
938
|
+
let mode = "help";
|
|
939
|
+
const update = () => {
|
|
940
|
+
const way = choice.get();
|
|
941
|
+
const shown = running || way !== void 0;
|
|
942
|
+
button.hidden = !shown;
|
|
943
|
+
own.hidden = !shown;
|
|
944
|
+
const state = progress.state(exercise.id, n());
|
|
945
|
+
const done = state.rightAt !== void 0 || state.revealed;
|
|
946
|
+
mode = running && done ? "back" : state.attempts > 0 && !done ? "why" : "help";
|
|
947
|
+
const name = running ? message(lang, "tutorSomeone") : nameOf(way, lang);
|
|
948
|
+
label.textContent = message(lang, mode === "back" ? "helpBack" : mode === "why" ? "helpWhy" : "help");
|
|
949
|
+
button.setAttribute("aria-label", message(lang, mode === "back" ? "helpBackLabel" : mode === "why" ? "helpWhyLabel" : "helpLabel", { name }));
|
|
950
|
+
button.replaceChildren(iconOf(root, way), label);
|
|
951
|
+
button.classList.toggle("explainer-help-why", mode === "why");
|
|
952
|
+
button.classList.toggle("explainer-help-back", mode === "back");
|
|
953
|
+
text.textContent = mode !== "why" ? "" : running ? message(lang, "stuckPrivacyCopy") : message(lang, "stuckPrivacy", { name });
|
|
954
|
+
note.hidden = !shown || mode !== "why" && field.hidden && status.textContent === "";
|
|
955
|
+
};
|
|
956
|
+
const stuck = () => {
|
|
957
|
+
const href = root.location.href;
|
|
958
|
+
const number = n();
|
|
959
|
+
return {
|
|
960
|
+
exercise: exercise.id,
|
|
961
|
+
...number === void 0 ? {} : { number },
|
|
962
|
+
state: progress.state(exercise.id, number),
|
|
963
|
+
tutor: tutorAddress(href, data, running ? taskQuery(href, page, exercise.id) : withNeeds(taskQuery(href, page, exercise.id), chosenNeeds(data.storage)))
|
|
964
|
+
};
|
|
965
|
+
};
|
|
966
|
+
button.addEventListener("click", () => {
|
|
967
|
+
note.hidden = false;
|
|
968
|
+
if (mode === "back") {
|
|
969
|
+
const name = chatName(data, lang);
|
|
970
|
+
const line = resultOf(root.location.href, lang, data, progress, exercise.id);
|
|
971
|
+
if (line === void 0) status.textContent = message(lang, "backToTutorOpen", { name });
|
|
972
|
+
else copyForChat(root, line, button, status, field, {
|
|
973
|
+
copied: message(lang, "backToTutorCopied", { name }),
|
|
974
|
+
failed: message(lang, "backToTutorFailed", { name })
|
|
975
|
+
});
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (running) {
|
|
979
|
+
const name = chatName(data, lang);
|
|
980
|
+
copyForChat(root, stuckLines(stuck(), lang), button, status, field, {
|
|
981
|
+
copied: message(lang, "stuckCopied", { name }),
|
|
982
|
+
failed: message(lang, "stuckFailed", { name })
|
|
983
|
+
});
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const way = choice.get();
|
|
987
|
+
const prompt = () => withLanguage(stuckPrompt(stuck(), lang), chosenNeeds(data.storage), lang);
|
|
988
|
+
const copy = () => withMarkdown(prompt(), data, root.location.href);
|
|
989
|
+
openAssistant(root, way, {
|
|
990
|
+
start: prompt,
|
|
991
|
+
copy,
|
|
992
|
+
placeholder: data.placeholder
|
|
993
|
+
}, (copied) => {
|
|
994
|
+
status.textContent = copied === void 0 ? "" : copied ? data.copied : data.failed;
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
place();
|
|
998
|
+
update();
|
|
999
|
+
element.addEventListener(SHOWN_EVENT, () => {
|
|
1000
|
+
place();
|
|
1001
|
+
update();
|
|
1002
|
+
});
|
|
1003
|
+
updates.set(exercise.id, update);
|
|
1004
|
+
}
|
|
1005
|
+
progress.listen((id) => updates.get(id)?.());
|
|
1006
|
+
choice.listen(() => {
|
|
1007
|
+
for (const update of updates.values()) update();
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
/** Shows in `hint` how the chosen option works when it says so (data-hint, a way that needs pasting); else hides it. */
|
|
1011
|
+
function showHint(select, hint) {
|
|
1012
|
+
if (!hint) return;
|
|
1013
|
+
const text = select.selectedOptions[0]?.dataset["hint"] ?? "";
|
|
1014
|
+
hint.textContent = text;
|
|
1015
|
+
hint.hidden = text === "";
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* The choice of the learner's assistant on the start page: "Keiner", the app's assistants and copying the text, shown
|
|
1019
|
+
* once the client module runs; a choice is kept on the device at once (WayChoice), "Keiner" switches the tutor off.
|
|
1020
|
+
*/
|
|
1021
|
+
function startChoice(root) {
|
|
1022
|
+
const select = root.querySelector("select.explainer-tutor-choose");
|
|
1023
|
+
const box = select?.closest(".explainer-tutor-choice");
|
|
1024
|
+
if (!select || !box) return;
|
|
1025
|
+
const storage = createStorage(select.dataset["storage"] ?? "explainer");
|
|
1026
|
+
const stored = storage.load(WAY_KEY, isText);
|
|
1027
|
+
const names = [...select.options].map((option) => option.value);
|
|
1028
|
+
select.value = stored !== void 0 && names.includes(stored) ? stored : "";
|
|
1029
|
+
const hint = box.nextElementSibling instanceof HTMLElement ? box.nextElementSibling : null;
|
|
1030
|
+
const explain = () => showHint(select, hint);
|
|
1031
|
+
select.addEventListener("change", () => {
|
|
1032
|
+
if (select.value === "") storage.remove(WAY_KEY);
|
|
1033
|
+
else storage.save(WAY_KEY, select.value);
|
|
1034
|
+
explain();
|
|
1035
|
+
});
|
|
1036
|
+
explain();
|
|
1037
|
+
box.hidden = false;
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* The choice of the learner's needs on the start page (dec-tutor-needs), put into the page from its <template>:
|
|
1041
|
+
* "Einstellungen" beside the choice of the assistant opens a dialog with a box per need, the first language once `andere-muttersprache` is ticked, and whether
|
|
1042
|
+
* the choice holds for every explainer or for this app only; each change is kept on the device at once as a setting
|
|
1043
|
+
* (createSettings), where the learner said.
|
|
1044
|
+
*/
|
|
1045
|
+
function startNeeds(root) {
|
|
1046
|
+
const template = root.querySelector("template.explainer-tutor-needs-template");
|
|
1047
|
+
template?.replaceWith(root.importNode(template.content, true));
|
|
1048
|
+
const box = root.querySelector("dialog.explainer-tutor-needs");
|
|
1049
|
+
const open = root.querySelector("button.explainer-tutor-needs-open");
|
|
1050
|
+
if (!box || !open) return;
|
|
1051
|
+
open.addEventListener("click", () => box.showModal());
|
|
1052
|
+
const settings = createSettings(box.dataset["storage"] ?? "explainer", ARCHETYPE);
|
|
1053
|
+
const boxes = [...box.querySelectorAll("input.explainer-tutor-need")];
|
|
1054
|
+
const scopes = [...box.querySelectorAll("input.explainer-tutor-needs-scope")];
|
|
1055
|
+
const language = box.querySelector("select.explainer-tutor-language");
|
|
1056
|
+
const languageBox = language?.closest(".explainer-tutor-language-choice");
|
|
1057
|
+
const stored = settings.load(NEEDS_KEY, isNeedsChoice);
|
|
1058
|
+
for (const input of boxes) input.checked = stored?.value.needs.includes(input.value) === true;
|
|
1059
|
+
const where = stored?.scope === "app" ? "app" : "archetype";
|
|
1060
|
+
for (const input of scopes) input.checked = input.value === where;
|
|
1061
|
+
if (language) {
|
|
1062
|
+
const tags = [...language.options].map((option) => option.value);
|
|
1063
|
+
const tag = stored?.value.language;
|
|
1064
|
+
language.value = tag !== void 0 && tags.includes(tag) ? tag : "";
|
|
1065
|
+
}
|
|
1066
|
+
const showLanguage = () => {
|
|
1067
|
+
if (languageBox) languageBox.hidden = !boxes.some((input) => input.checked && input.value === "andere-muttersprache");
|
|
1068
|
+
};
|
|
1069
|
+
const save = () => {
|
|
1070
|
+
const needs = boxes.filter((input) => input.checked).map((input) => input.value);
|
|
1071
|
+
const tag = language?.value ?? "";
|
|
1072
|
+
const choice = {
|
|
1073
|
+
needs,
|
|
1074
|
+
...tag !== "" && needs.includes("andere-muttersprache") ? { language: tag } : {}
|
|
1075
|
+
};
|
|
1076
|
+
const scope = scopes.find((input) => input.checked)?.value === "app" ? "app" : "archetype";
|
|
1077
|
+
settings.save(NEEDS_KEY, choice, scope);
|
|
1078
|
+
showLanguage();
|
|
1079
|
+
};
|
|
1080
|
+
box.addEventListener("change", save);
|
|
1081
|
+
showLanguage();
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* "Mit einem KI-Tutor lernen", way A, beside the title of a content page. While no way is switched on, it stays
|
|
1085
|
+
* folded; unfolded, a select of the app's assistants and copying the text, and "Einschalten", which keeps the way on
|
|
1086
|
+
* the device (WayChoice). Once a way is on, a bar takes its place: "Mit <name> lernen", with the assistant's symbol;
|
|
1087
|
+
* the start page changes the way or switches it off. The bar's button does the way: it opens the assistant with the
|
|
1088
|
+
* start prompt in its address, the page's tutor page with the page's task numbers (prompt.ts, pageQuery), for a
|
|
1089
|
+
* tutor who leads through the page before it comes to a task; or it copies the prompt with the tutor page's Markdown, and opens
|
|
1090
|
+
* an assistant whose address takes no prompt. On a page a tutor opened (via=tutor) the chat runs already: the section
|
|
1091
|
+
* stays hidden. Nothing goes over the network but the click's own page, the tutor page's Markdown (the app's own
|
|
1092
|
+
* static file, for copying) and the assistant that opens.
|
|
1093
|
+
*/
|
|
1094
|
+
function startTutor(root, data, choice) {
|
|
1095
|
+
const section = root.querySelector(".explainer-tutor");
|
|
1096
|
+
if (!section) return;
|
|
1097
|
+
if (fromTutor(root.location.href)) {
|
|
1098
|
+
section.hidden = true;
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
const off = section.querySelector("details.explainer-tutor-off");
|
|
1102
|
+
const bar = section.querySelector(".explainer-tutor-bar");
|
|
1103
|
+
const way = section.querySelector(".explainer-tutor-way");
|
|
1104
|
+
const select = section.querySelector(".explainer-tutor-select");
|
|
1105
|
+
const on = section.querySelector(".explainer-tutor-on");
|
|
1106
|
+
const go = section.querySelector(".explainer-tutor-go");
|
|
1107
|
+
const status = section.querySelector(".explainer-tutor-status");
|
|
1108
|
+
if (!off || !bar || !way || !select || !on || !go) return;
|
|
1109
|
+
let reset;
|
|
1110
|
+
const done = root.createElement("span");
|
|
1111
|
+
done.className = "explainer-tutor-go-done";
|
|
1112
|
+
const say = (text, state) => {
|
|
1113
|
+
clearTimeout(reset);
|
|
1114
|
+
bar.toggleAttribute("data-done", state === "ok");
|
|
1115
|
+
if (state === "ok") go.append(done);
|
|
1116
|
+
else done.remove();
|
|
1117
|
+
if (!status) return;
|
|
1118
|
+
status.textContent = text;
|
|
1119
|
+
if (state === void 0) delete status.dataset["state"];
|
|
1120
|
+
else status.dataset["state"] = state;
|
|
1121
|
+
if (state === "ok") reset = setTimeout(() => say(""), DONE_MS);
|
|
1122
|
+
};
|
|
1123
|
+
way.hidden = false;
|
|
1124
|
+
const hint = section.querySelector(".explainer-tutor-hint");
|
|
1125
|
+
select.addEventListener("change", () => showHint(select, hint));
|
|
1126
|
+
showHint(select, hint);
|
|
1127
|
+
const plain = section.querySelector(".explainer-tutor-plain");
|
|
1128
|
+
if (plain) plain.hidden = true;
|
|
1129
|
+
const show = () => {
|
|
1130
|
+
const chosen = choice.get();
|
|
1131
|
+
off.hidden = chosen !== void 0;
|
|
1132
|
+
bar.hidden = chosen === void 0;
|
|
1133
|
+
if (chosen === void 0) return;
|
|
1134
|
+
const text = root.createElement("span");
|
|
1135
|
+
text.className = "explainer-tutor-go-text";
|
|
1136
|
+
text.textContent = chosen.url === void 0 ? data.copy : data.go.replace("{name}", chosen.name);
|
|
1137
|
+
done.textContent = data.done.replace("{name}", nameOf(chosen, root.documentElement.lang));
|
|
1138
|
+
go.replaceChildren(iconOf(root, chosen), text);
|
|
1139
|
+
};
|
|
1140
|
+
show();
|
|
1141
|
+
choice.listen(show);
|
|
1142
|
+
on.addEventListener("click", () => {
|
|
1143
|
+
off.open = false;
|
|
1144
|
+
choice.set(select.value);
|
|
1145
|
+
say("");
|
|
1146
|
+
go.focus();
|
|
1147
|
+
});
|
|
1148
|
+
const tree = new URL(data.root, root.location.href).href;
|
|
1149
|
+
/** The start prompt: the page's tutor page with the page's task numbers and the learner's needs. */
|
|
1150
|
+
const start = () => {
|
|
1151
|
+
const text = data.start.split(data.base).join(tree);
|
|
1152
|
+
const { page } = data;
|
|
1153
|
+
const needs = chosenNeeds(data.storage);
|
|
1154
|
+
const query = withNeeds(page === void 0 ? "" : pageQuery(root.location.href, page), needs);
|
|
1155
|
+
return withLanguage(text.split(data.query).join(query), needs, root.documentElement.lang);
|
|
1156
|
+
};
|
|
1157
|
+
const whole = () => withMarkdown(start(), data, root.location.href);
|
|
1158
|
+
go.addEventListener("click", () => {
|
|
1159
|
+
openAssistant(root, choice.get(), {
|
|
1160
|
+
start,
|
|
1161
|
+
copy: whole,
|
|
1162
|
+
placeholder: data.placeholder
|
|
1163
|
+
}, (copied) => {
|
|
1164
|
+
say(copied === void 0 ? "" : copied ? data.copied : data.failed, copied === void 0 ? void 0 : copied ? "ok" : "failed");
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
/** Starts the explainer on the page. */
|
|
1169
|
+
function startExplainer(root = document) {
|
|
1170
|
+
root.documentElement.classList.add("explainer-js");
|
|
1171
|
+
const main = root.querySelector("main");
|
|
1172
|
+
const tutor = tutorData(root);
|
|
1173
|
+
const progress = new Progress();
|
|
1174
|
+
const assessment = root.querySelector(".explainer-assessment-nojs") !== null;
|
|
1175
|
+
if (main && fromTutor(root.location.href) && !assessment) startBackToTutor(root, main, tutor, progress);
|
|
1176
|
+
startChoice(root);
|
|
1177
|
+
startNeeds(root);
|
|
1178
|
+
if (tutor !== void 0) {
|
|
1179
|
+
const choice = new WayChoice(tutor.storage, tutor.ways);
|
|
1180
|
+
startTutor(root, tutor, choice);
|
|
1181
|
+
startHelp(root, tutor, progress, choice);
|
|
1182
|
+
}
|
|
1183
|
+
root.addEventListener(ANSWERED_EVENT, (event) => {
|
|
1184
|
+
const element = event.target;
|
|
1185
|
+
const detail = event.detail;
|
|
1186
|
+
if (!(element instanceof HTMLElement) || element.id === "" || detail === null) return;
|
|
1187
|
+
const text = (value) => typeof value === "string" ? value : void 0;
|
|
1188
|
+
progress.answered(element.id, numberFromAddress(root.location.href, element.id), detail.correct === true, text(detail.answer), text(detail.message));
|
|
1189
|
+
});
|
|
1190
|
+
startVideos(root);
|
|
1191
|
+
startAssessment(root, {
|
|
1192
|
+
chosen: (storage, ways) => new WayChoice(storage, ways).get(),
|
|
1193
|
+
needs: chosenNeeds,
|
|
1194
|
+
open: (way, prompts, say) => openAssistant(root, way, prompts, say),
|
|
1195
|
+
hint: showHint,
|
|
1196
|
+
running: fromTutor(root.location.href),
|
|
1197
|
+
back: (text, button, place, storage, ways) => {
|
|
1198
|
+
const lang = root.documentElement.lang;
|
|
1199
|
+
const { status, field } = statusAndField(root, message(lang, "resultLabel"));
|
|
1200
|
+
place.querySelector(":scope > .explainer-assessment-back-status")?.remove();
|
|
1201
|
+
const line = root.createElement("p");
|
|
1202
|
+
line.className = "explainer-assessment-back-status";
|
|
1203
|
+
line.append(status, field);
|
|
1204
|
+
place.append(line);
|
|
1205
|
+
const name = chatName({
|
|
1206
|
+
storage,
|
|
1207
|
+
ways
|
|
1208
|
+
}, lang);
|
|
1209
|
+
copyForChat(root, text, button, status, field, {
|
|
1210
|
+
copied: message(lang, "backToTutorCopied", { name }),
|
|
1211
|
+
failed: message(lang, "backToTutorFailed", { name })
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
for (const script of root.querySelectorAll("script.explainer-exercise-data")) {
|
|
1216
|
+
const element = script.parentElement;
|
|
1217
|
+
if (!element) continue;
|
|
1218
|
+
const data = JSON.parse(script.textContent ?? "{}");
|
|
1219
|
+
if (data.kind === "question") {
|
|
1220
|
+
const item = element.querySelector(".question");
|
|
1221
|
+
if (!item) continue;
|
|
1222
|
+
const solution = element.querySelector(":scope > details.explainer-solution");
|
|
1223
|
+
if (solution) solution.hidden = true;
|
|
1224
|
+
const { rendered, question, lang } = data;
|
|
1225
|
+
const id = element.id;
|
|
1226
|
+
startQuestion(item, question, {
|
|
1227
|
+
lang,
|
|
1228
|
+
retry: true,
|
|
1229
|
+
format: (text) => rendered[text] ?? escape(text),
|
|
1230
|
+
onAnswer: (answer, right, said) => progress.answered(id, void 0, right, answerShown(question, answer, lang), said),
|
|
1231
|
+
onReveal: () => progress.revealed(id, void 0)
|
|
1232
|
+
});
|
|
1233
|
+
} else if (data.kind === "open") {
|
|
1234
|
+
const { id, lang, points } = data;
|
|
1235
|
+
startOpenTask(element, data, { onResult: (result, attempt) => progress.compared(id, result.ticked.length, points.length, pointNames(result.missed), result.photo ? message(lang, "resultPhoto") : shortened(result.text ?? ""), attempt) });
|
|
1236
|
+
} else startGenerated(element, data, progress);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
/** How much of an open answer a result line carries: the rest is the learner's to paste, if the tutor asks. */
|
|
1240
|
+
const ANSWER_IN_LINE = 2e3;
|
|
1241
|
+
/** An open answer on one line, at most ANSWER_IN_LINE characters. */
|
|
1242
|
+
const shortened = (text) => {
|
|
1243
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
1244
|
+
return line.length <= ANSWER_IN_LINE ? line : `${line.slice(0, ANSWER_IN_LINE)} …`;
|
|
1245
|
+
};
|
|
1246
|
+
export { ANSWERED_EVENT, fromTutor, startExplainer };
|