@webwriter/quiz 1.1.3 → 2.0.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/.github/workflows/publish.yml +8 -0
- package/LICENSE +6 -6
- package/README.md +778 -0
- package/assets/icons/grip-horizontal.svg +8 -0
- package/assets/icons/grip-vertical.svg +8 -0
- package/assets/icons/truefalse.svg +6 -0
- package/custom-elements.json +2779 -0
- package/custom.d.ts +180 -176
- package/dist/api/index.d.ts +27 -0
- package/dist/api/index.js +40 -0
- package/dist/migrate.js +1434 -0
- package/dist/widgets/webwriter-choice-item.js +4036 -3895
- package/dist/widgets/webwriter-choice.js +2269 -5821
- package/dist/widgets/webwriter-gap-item.js +3987 -0
- package/dist/widgets/webwriter-gap.js +2908 -0
- package/dist/widgets/webwriter-mark.js +4335 -3489
- package/dist/widgets/webwriter-order-item.js +4014 -3306
- package/dist/widgets/webwriter-order.js +2944 -4386
- package/dist/widgets/webwriter-pairing-item.js +1058 -2421
- package/dist/widgets/webwriter-pairing.js +2993 -4383
- package/dist/widgets/webwriter-quiz-hint.js +2469 -0
- package/dist/widgets/webwriter-quiz.js +4933 -6099
- package/dist/widgets/webwriter-speech.js +6077 -5230
- package/dist/widgets/webwriter-task-prompt.js +4775 -2165
- package/dist/widgets/webwriter-task.js +2364 -8382
- package/dist/widgets/webwriter-text.js +4169 -4337
- package/dist/widgets/webwriter-true-false.js +4674 -0
- package/editing-config.json +106 -0
- package/lit-localize.json +21 -15
- package/localization/de.xlf +407 -74
- package/localization/es.xlf +407 -69
- package/localization/fr.xlf +407 -69
- package/localization/generated/de.ts +112 -29
- package/localization/generated/es.ts +112 -29
- package/localization/generated/fr.ts +112 -29
- package/localization/generated/index.d.ts +5 -0
- package/localization/generated/index.js +2 -2
- package/localization/generated/it.ts +112 -29
- package/localization/generated/locale-codes.js +31 -79
- package/localization/generated/nl.ts +112 -29
- package/localization/it.xlf +407 -69
- package/localization/nl.xlf +407 -69
- package/package.json +119 -252
- package/scripts/apply-translations.mjs +97 -0
- package/src/api/index.ts +72 -0
- package/src/lib/artboard.ts +686 -0
- package/src/lib/draggable-grid.ts +432 -0
- package/src/lib/encrypted-property.ts +202 -0
- package/src/lib/hint-popup.ts +152 -0
- package/src/lib/utils.ts +97 -0
- package/src/lib/webwriter-quirks.ts +121 -0
- package/src/migrate/index.ts +622 -0
- package/src/migrate/legacy-solution.ts +362 -0
- package/src/snippets/order.html +9 -8
- package/src/widgets/choice/webwriter-choice-item.ts +316 -0
- package/src/widgets/choice/webwriter-choice.ts +328 -0
- package/src/widgets/choice/webwriter-true-false.ts +113 -0
- package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
- package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
- package/src/widgets/dnd/webwriter-dnd.ts +36 -0
- package/src/widgets/gap/webwriter-gap-item.ts +250 -0
- package/src/widgets/gap/webwriter-gap.ts +447 -0
- package/src/widgets/order/webwriter-order-item.ts +243 -0
- package/src/widgets/order/webwriter-order.ts +282 -0
- package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
- package/src/widgets/pairing/webwriter-pairing.ts +934 -0
- package/src/widgets/speech/audio-player.ts +325 -0
- package/src/widgets/speech/audio-recorder.ts +285 -0
- package/src/widgets/speech/waveform.ts +113 -0
- package/src/widgets/speech/webwriter-speech.ts +87 -0
- package/src/widgets/webwriter-mark.ts +374 -432
- package/src/widgets/webwriter-quiz-hint.ts +36 -0
- package/src/widgets/webwriter-quiz.ts +320 -282
- package/src/widgets/webwriter-task-prompt.ts +49 -24
- package/src/widgets/webwriter-task.ts +216 -547
- package/src/widgets/webwriter-text.ts +291 -156
- package/tsconfig.api.json +12 -0
- package/tsconfig.json +8 -7
- package/dist/widgets/webwriter-choice.css +0 -444
- package/dist/widgets/webwriter-cloze-gap.css +0 -444
- package/dist/widgets/webwriter-cloze-gap.js +0 -5866
- package/dist/widgets/webwriter-cloze.css +0 -444
- package/dist/widgets/webwriter-cloze.js +0 -3786
- package/dist/widgets/webwriter-mark.css +0 -444
- package/dist/widgets/webwriter-order.css +0 -444
- package/dist/widgets/webwriter-pairing-item.css +0 -444
- package/dist/widgets/webwriter-pairing.css +0 -444
- package/dist/widgets/webwriter-quiz.css +0 -444
- package/dist/widgets/webwriter-task-explainer.css +0 -444
- package/dist/widgets/webwriter-task-explainer.js +0 -2872
- package/dist/widgets/webwriter-task-hint.css +0 -444
- package/dist/widgets/webwriter-task-hint.js +0 -2811
- package/dist/widgets/webwriter-task.css +0 -444
- package/dist/widgets/webwriter-text.css +0 -444
- package/localization/bg.xlf +0 -69
- package/localization/cs.xlf +0 -69
- package/localization/da.xlf +0 -69
- package/localization/el.xlf +0 -69
- package/localization/et.xlf +0 -69
- package/localization/fi.xlf +0 -69
- package/localization/generated/bg.ts +0 -30
- package/localization/generated/cs.ts +0 -30
- package/localization/generated/da.ts +0 -30
- package/localization/generated/el.ts +0 -30
- package/localization/generated/et.ts +0 -30
- package/localization/generated/fi.ts +0 -30
- package/localization/generated/hu.ts +0 -30
- package/localization/generated/id.ts +0 -30
- package/localization/generated/ja.ts +0 -30
- package/localization/generated/ko.ts +0 -30
- package/localization/generated/lt.ts +0 -30
- package/localization/generated/lv.ts +0 -30
- package/localization/generated/nb.ts +0 -30
- package/localization/generated/pl.ts +0 -30
- package/localization/generated/pt-BR.ts +0 -30
- package/localization/generated/pt-PT.ts +0 -30
- package/localization/generated/ro.ts +0 -30
- package/localization/generated/ru.ts +0 -30
- package/localization/generated/sk.ts +0 -30
- package/localization/generated/sl.ts +0 -30
- package/localization/generated/sv.ts +0 -30
- package/localization/generated/tr.ts +0 -30
- package/localization/generated/uk.ts +0 -30
- package/localization/generated/zh-hans.ts +0 -30
- package/localization/hu.xlf +0 -69
- package/localization/id.xlf +0 -69
- package/localization/ja.xlf +0 -69
- package/localization/ko.xlf +0 -69
- package/localization/lt.xlf +0 -69
- package/localization/lv.xlf +0 -69
- package/localization/nb.xlf +0 -69
- package/localization/pl.xlf +0 -69
- package/localization/pt-BR.xlf +0 -69
- package/localization/pt-PT.xlf +0 -69
- package/localization/ro.xlf +0 -69
- package/localization/ru.xlf +0 -69
- package/localization/sk.xlf +0 -69
- package/localization/sl.xlf +0 -69
- package/localization/sv.xlf +0 -69
- package/localization/tr.xlf +0 -69
- package/localization/uk.xlf +0 -69
- package/localization/zh-hans.xlf +0 -69
- package/src/lib/combobox.ts +0 -456
- package/src/lib/highlighter-fill.svg +0 -6
- package/src/snippets/Beispiel-Choice.html +0 -57
- package/src/snippets/Beispiel-Mark.html +0 -14
- package/src/snippets/Beispiel-Order.html +0 -52
- package/src/snippets/Beispiel-Text.html +0 -20
- package/src/snippets/blank.html +0 -1
- package/src/snippets/choice.html +0 -8
- package/src/snippets/cloze.html +0 -4
- package/src/snippets/mark.html +0 -4
- package/src/snippets/pairing.html +0 -10
- package/src/snippets/speech.html +0 -4
- package/src/snippets/text.html +0 -4
- package/src/snippets/wordsearch.html +0 -4
- package/src/widgets/webwriter-choice-item.ts +0 -254
- package/src/widgets/webwriter-choice.ts +0 -353
- package/src/widgets/webwriter-cloze-gap.ts +0 -216
- package/src/widgets/webwriter-cloze.ts +0 -136
- package/src/widgets/webwriter-order-item.ts +0 -441
- package/src/widgets/webwriter-order.ts +0 -327
- package/src/widgets/webwriter-pairing-item.ts +0 -161
- package/src/widgets/webwriter-pairing.ts +0 -193
- package/src/widgets/webwriter-speech.ts +0 -273
- package/src/widgets/webwriter-task-explainer.ts +0 -43
- package/src/widgets/webwriter-task-hint.ts +0 -23
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrates quiz content authored with `@webwriter/quiz` v1 to the v2 element
|
|
3
|
+
* set. WebWriter loads this script into an offscreen iframe before parsing a
|
|
4
|
+
* document and dispatches a `migrate` event on every outdated widget, innermost
|
|
5
|
+
* first, so each handler can assume its own children were already migrated.
|
|
6
|
+
*
|
|
7
|
+
* The dispatching document is snapshotted as soon as the iframe fires `load`,
|
|
8
|
+
* which means everything here has to run synchronously — see
|
|
9
|
+
* `./legacy-solution` for why the v1 solution crypto is reimplemented by
|
|
10
|
+
* hand instead of using `crypto.subtle`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { name as packageName, version as packageVersion } from "../../package.json";
|
|
14
|
+
import { encryptedProperty } from "../lib/encrypted-property";
|
|
15
|
+
import { decryptLegacySolution } from "./legacy-solution";
|
|
16
|
+
|
|
17
|
+
/** Documents written by this major version (or newer) are left alone. */
|
|
18
|
+
const CURRENT_MAJOR_VERSION = Number(packageVersion.split(".")[0]);
|
|
19
|
+
|
|
20
|
+
/** Tags that can act as the answer of a `<webwriter-task>` after migration. */
|
|
21
|
+
const ANSWER_TAGS = new Set([
|
|
22
|
+
"webwriter-choice",
|
|
23
|
+
"webwriter-true-false",
|
|
24
|
+
"webwriter-order",
|
|
25
|
+
"webwriter-text",
|
|
26
|
+
"webwriter-mark",
|
|
27
|
+
"webwriter-gap",
|
|
28
|
+
"webwriter-pairing",
|
|
29
|
+
"webwriter-speech",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** Quizzes this script created to host tasks that used to stand on their own. */
|
|
33
|
+
const generatedQuizzes = new WeakSet<Element>();
|
|
34
|
+
|
|
35
|
+
/** Clozes whose gaps offered a list of options, which v2 models as drag and drop. */
|
|
36
|
+
const dragAndDropClozes = new WeakSet<Element>();
|
|
37
|
+
|
|
38
|
+
/** Decrypted v1 task solutions, since decryption costs ~150ms per task. */
|
|
39
|
+
const taskSolutions = new WeakMap<Element, unknown>();
|
|
40
|
+
|
|
41
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
42
|
+
|
|
43
|
+
function warn(message: string, ...details: unknown[]) {
|
|
44
|
+
console.warn(`[${packageName}] ${message}`, ...details);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function uniqueId() {
|
|
48
|
+
const uuid = crypto.randomUUID?.() ?? `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
|
|
49
|
+
return `ww-${uuid}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Marks `el` as belonging to the current version of this package. */
|
|
53
|
+
function markAsCurrent(el: Element) {
|
|
54
|
+
for (const cls of Array.from(el.classList)) {
|
|
55
|
+
if (cls.startsWith("ww-v")) el.classList.remove(cls);
|
|
56
|
+
}
|
|
57
|
+
el.classList.add("ww-widget", `ww-v${packageVersion}`, `ww-pkg-${packageName}`);
|
|
58
|
+
if (!el.id) el.id = uniqueId();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createWidget(tag: string, id?: string) {
|
|
62
|
+
const el = document.createElement(tag);
|
|
63
|
+
if (id) el.id = id;
|
|
64
|
+
markAsCurrent(el);
|
|
65
|
+
return el;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Replaces `el` with an equivalent `tag` element, keeping its id, attributes and children. */
|
|
69
|
+
function retag(el: Element, tag: string) {
|
|
70
|
+
const replacement = document.createElement(tag);
|
|
71
|
+
for (const name of el.getAttributeNames()) replacement.setAttribute(name, el.getAttribute(name)!);
|
|
72
|
+
replacement.append(...Array.from(el.childNodes));
|
|
73
|
+
markAsCurrent(replacement);
|
|
74
|
+
el.replaceWith(replacement);
|
|
75
|
+
return replacement;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function removeAttributes(el: Element, ...names: string[]) {
|
|
79
|
+
for (const name of names) el.removeAttribute(name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renameAttribute(el: Element, from: string, to: string) {
|
|
83
|
+
if (!el.hasAttribute(from)) return;
|
|
84
|
+
el.setAttribute(to, el.getAttribute(from)!);
|
|
85
|
+
el.removeAttribute(from);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseJson(value: string | null): unknown {
|
|
89
|
+
if (!value) return undefined;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(value);
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Writes a solution the way v2 expects it. The value is obfuscated with the
|
|
99
|
+
* same converter the widgets use, so it never lands in the document as
|
|
100
|
+
* readable text.
|
|
101
|
+
*/
|
|
102
|
+
function setSolution(el: Element, value: unknown) {
|
|
103
|
+
const attribute = encryptedProperty.toAttribute?.(value, Array);
|
|
104
|
+
if (typeof attribute === "string") el.setAttribute("solution", attribute);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reads the solution of the `<webwriter-task>` containing `el`. v1 stored the
|
|
109
|
+
* answer element's solution on the task, encrypted with a hardcoded password.
|
|
110
|
+
*/
|
|
111
|
+
function getTaskSolution(el: Element): unknown {
|
|
112
|
+
const task = el.closest("webwriter-task");
|
|
113
|
+
if (!task) return undefined;
|
|
114
|
+
if (taskSolutions.has(task)) return taskSolutions.get(task);
|
|
115
|
+
|
|
116
|
+
const solution = task.getAttribute("solution");
|
|
117
|
+
const iv = task.getAttribute("iv");
|
|
118
|
+
const salt = task.getAttribute("salt");
|
|
119
|
+
let value: unknown = undefined;
|
|
120
|
+
|
|
121
|
+
if (solution && iv && salt) {
|
|
122
|
+
try {
|
|
123
|
+
value = decryptLegacySolution(solution, iv, salt);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
warn("Could not decrypt the solution of a task, it will have to be entered again.", task, error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
taskSolutions.set(task, value);
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Quiz, task and prompt
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function migrateQuiz(el: Element) {
|
|
138
|
+
// v2 has neither randomized task order nor task counters.
|
|
139
|
+
removeAttributes(el, "randomorder", "counter", "submitted");
|
|
140
|
+
markAsCurrent(el);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Returns the task's prompt, creating one if the task somehow lost it. */
|
|
144
|
+
function ensurePrompt(task: Element) {
|
|
145
|
+
let prompt = task.querySelector(":scope > webwriter-task-prompt");
|
|
146
|
+
if (!prompt) {
|
|
147
|
+
prompt = createWidget("webwriter-task-prompt");
|
|
148
|
+
prompt.appendChild(document.createElement("p"));
|
|
149
|
+
task.insertBefore(prompt, task.firstChild);
|
|
150
|
+
}
|
|
151
|
+
prompt.setAttribute("slot", "prompt");
|
|
152
|
+
return prompt;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* v1 tasks carried their own submit button, so they were usable on their own.
|
|
157
|
+
* In v2 grading lives on the quiz, so standalone tasks need a quiz around them.
|
|
158
|
+
* Consecutive tasks are collected into a single quiz.
|
|
159
|
+
*/
|
|
160
|
+
function ensureQuiz(task: Element) {
|
|
161
|
+
const parent = task.parentElement;
|
|
162
|
+
if (!parent || parent.localName === "webwriter-quiz") return;
|
|
163
|
+
|
|
164
|
+
const previous = task.previousElementSibling;
|
|
165
|
+
if (previous && generatedQuizzes.has(previous)) {
|
|
166
|
+
previous.appendChild(task);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const quiz = createWidget("webwriter-quiz");
|
|
171
|
+
generatedQuizzes.add(quiz);
|
|
172
|
+
parent.insertBefore(quiz, task);
|
|
173
|
+
quiz.appendChild(task);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function migrateTask(el: Element) {
|
|
177
|
+
const solution = getTaskSolution(el);
|
|
178
|
+
removeAttributes(el, "solution", "iv", "salt", "hint", "submitted", "counter");
|
|
179
|
+
applyAnswerSolution(el, solution);
|
|
180
|
+
ensurePrompt(el);
|
|
181
|
+
markAsCurrent(el);
|
|
182
|
+
ensureQuiz(el);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function migratePrompt(el: Element) {
|
|
186
|
+
el.setAttribute("slot", "prompt");
|
|
187
|
+
markAsCurrent(el);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Elements that cannot survive inside the `p+` content of a hint. */
|
|
191
|
+
const BLOCK_TAGS = new Set(["div", "section", "article", "ul", "ol", "li", "blockquote", "pre", "figure"]);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Appends `nodes` to `target` as paragraphs, since `<webwriter-quiz-hint>`
|
|
195
|
+
* accepts `p+` while v1 hints and explainers accepted arbitrary flow content.
|
|
196
|
+
*/
|
|
197
|
+
function appendAsParagraphs(target: Element, nodes: Node[]) {
|
|
198
|
+
let paragraph: HTMLParagraphElement | undefined;
|
|
199
|
+
|
|
200
|
+
for (const node of nodes) {
|
|
201
|
+
const tag = node.nodeType === Node.ELEMENT_NODE ? (node as Element).localName : undefined;
|
|
202
|
+
|
|
203
|
+
if (tag === "p") {
|
|
204
|
+
target.appendChild(node);
|
|
205
|
+
paragraph = undefined;
|
|
206
|
+
} else if (tag && (BLOCK_TAGS.has(tag) || /^h[1-6]$/.test(tag))) {
|
|
207
|
+
const replacement = document.createElement("p");
|
|
208
|
+
replacement.append(...Array.from(node.childNodes));
|
|
209
|
+
target.appendChild(replacement);
|
|
210
|
+
paragraph = undefined;
|
|
211
|
+
} else if (node.nodeType !== Node.TEXT_NODE || node.textContent?.trim()) {
|
|
212
|
+
if (!paragraph) {
|
|
213
|
+
paragraph = document.createElement("p");
|
|
214
|
+
target.appendChild(paragraph);
|
|
215
|
+
}
|
|
216
|
+
paragraph.appendChild(node);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Returns the task's single `<webwriter-quiz-hint>`, creating it if needed. The
|
|
223
|
+
* prompt slots it by name and expects it first, see `webwriter-task-prompt` in
|
|
224
|
+
* `editing-config.json` and `hint-popup`.
|
|
225
|
+
*/
|
|
226
|
+
function ensureHint(task: Element, id?: string) {
|
|
227
|
+
const prompt = ensurePrompt(task);
|
|
228
|
+
let hint = prompt.querySelector(":scope > webwriter-quiz-hint");
|
|
229
|
+
|
|
230
|
+
if (!hint) {
|
|
231
|
+
hint = createWidget("webwriter-quiz-hint", id);
|
|
232
|
+
hint.setAttribute("slot", "hint");
|
|
233
|
+
prompt.prepend(hint);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return hint;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Folds a v1 `<webwriter-task-hint>` or `<webwriter-task-explainer>` into the
|
|
241
|
+
* task's hint. v1 allowed one hint plus any number of explainers per task,
|
|
242
|
+
* while v2 has a single hint, so their contents are concatenated in document
|
|
243
|
+
* order.
|
|
244
|
+
*/
|
|
245
|
+
function foldIntoHint(el: Element, id?: string) {
|
|
246
|
+
const task = el.closest("webwriter-task");
|
|
247
|
+
|
|
248
|
+
if (task) {
|
|
249
|
+
appendAsParagraphs(ensureHint(task, id), Array.from(el.childNodes));
|
|
250
|
+
} else {
|
|
251
|
+
warn("Dropped a hint that was not inside a task.", el);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
el.remove();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function migrateHint(el: Element) {
|
|
258
|
+
foldIntoHint(el, el.id);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* v1 showed explainers as tabs after submitting. v2 has no such element, so
|
|
263
|
+
* their content is kept as part of the task's hint.
|
|
264
|
+
*/
|
|
265
|
+
function migrateExplainer(el: Element) {
|
|
266
|
+
warn("A task explainer became part of the task's hint, since v2 has no explainers.", el);
|
|
267
|
+
foldIntoHint(el);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Applies a decrypted v1 solution to the already migrated answer element. Gaps,
|
|
272
|
+
* marks and pairings keep their solution on the answer itself and are handled
|
|
273
|
+
* by their own migrations.
|
|
274
|
+
*/
|
|
275
|
+
function applyAnswerSolution(task: Element, solution: unknown) {
|
|
276
|
+
const answer = Array.from(task.children).find(child => ANSWER_TAGS.has(child.localName));
|
|
277
|
+
if (!answer || solution === undefined || solution === null) return;
|
|
278
|
+
|
|
279
|
+
if (answer.localName === "webwriter-choice" && Array.isArray(solution)) {
|
|
280
|
+
// v1 listed the ids of the correct options, v2 maps every option to a boolean.
|
|
281
|
+
const value: Record<string, boolean> = {};
|
|
282
|
+
for (const item of answer.querySelectorAll(":scope > webwriter-choice-item")) {
|
|
283
|
+
value[item.id] = solution.includes(item.id);
|
|
284
|
+
}
|
|
285
|
+
setSolution(answer, value);
|
|
286
|
+
} else if (answer.localName === "webwriter-order" && Array.isArray(solution)) {
|
|
287
|
+
const items = Array.from(answer.querySelectorAll(":scope > webwriter-order-item"));
|
|
288
|
+
const ordered = solution
|
|
289
|
+
.map(id => items.find(item => item.id === id))
|
|
290
|
+
.filter((item): item is Element => item !== undefined);
|
|
291
|
+
const rest = items.filter(item => !ordered.includes(item));
|
|
292
|
+
|
|
293
|
+
// v2 takes the authoring order as the solution, so sort the DOM instead.
|
|
294
|
+
const sorted = [...ordered, ...rest];
|
|
295
|
+
for (const item of sorted) answer.appendChild(item);
|
|
296
|
+
setSolution(
|
|
297
|
+
answer,
|
|
298
|
+
sorted.map(item => item.id),
|
|
299
|
+
);
|
|
300
|
+
} else if (answer.localName === "webwriter-text" && typeof solution === "string") {
|
|
301
|
+
setSolution(answer, solution);
|
|
302
|
+
} else if (answer.localName === "webwriter-pairing" && !answer.hasAttribute("solution")) {
|
|
303
|
+
applyPairingSolution(answer, Array.isArray(solution) ? solution : []);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Choice and order
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
/** v1 kept the layout on each item, v2 keeps it on the container. */
|
|
312
|
+
function hoistLayout(el: Element, containerTag: string) {
|
|
313
|
+
const layout = el.getAttribute("layout");
|
|
314
|
+
const container = el.parentElement;
|
|
315
|
+
if (layout && container?.localName === containerTag && !container.hasAttribute("layout")) {
|
|
316
|
+
container.setAttribute("layout", layout);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function migrateChoice(el: Element) {
|
|
321
|
+
const mode = el.getAttribute("mode") ?? "single";
|
|
322
|
+
|
|
323
|
+
if (mode === "truefalse") {
|
|
324
|
+
// v1 deleted all options in this mode and never stored which side was
|
|
325
|
+
// correct, so the author has to pick it again.
|
|
326
|
+
const trueFalse = createWidget("webwriter-true-false", el.id);
|
|
327
|
+
trueFalse.setAttribute("solution", "true");
|
|
328
|
+
const discarded = el.querySelectorAll(":scope > webwriter-choice-item").length;
|
|
329
|
+
el.replaceWith(trueFalse);
|
|
330
|
+
warn(
|
|
331
|
+
`A true/false question lost its solution, since v1 never stored one.${discarded ? ` ${discarded} leftover option(s) were dropped.` : ""}`,
|
|
332
|
+
trueFalse,
|
|
333
|
+
);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
el.setAttribute("mode", mode === "multiple" ? "multiple" : "single");
|
|
338
|
+
if (el.getAttribute("layout") !== "tiles") el.removeAttribute("layout");
|
|
339
|
+
if (el.hasAttribute("randomorder")) el.setAttribute("randomize-order", "");
|
|
340
|
+
// v1's `showsolution` is covered by the quiz-wide "detailed feedback" option.
|
|
341
|
+
removeAttributes(el, "randomorder", "showsolution");
|
|
342
|
+
|
|
343
|
+
if (!el.querySelector(":scope > webwriter-choice-item")) {
|
|
344
|
+
const item = createWidget("webwriter-choice-item");
|
|
345
|
+
item.appendChild(document.createElement("p"));
|
|
346
|
+
el.appendChild(item);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
markAsCurrent(el);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function migrateChoiceItem(el: Element) {
|
|
353
|
+
hoistLayout(el, "webwriter-choice");
|
|
354
|
+
removeAttributes(el, "layout");
|
|
355
|
+
markAsCurrent(el);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function migrateOrder(el: Element) {
|
|
359
|
+
if (el.getAttribute("layout") !== "tiles") el.removeAttribute("layout");
|
|
360
|
+
removeAttributes(el, "hideorderbuttons", "showsolution");
|
|
361
|
+
markAsCurrent(el);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function migrateOrderItem(el: Element) {
|
|
365
|
+
hoistLayout(el, "webwriter-order");
|
|
366
|
+
removeAttributes(el, "layout", "draggable", "droppreview", "hideorderbuttons");
|
|
367
|
+
markAsCurrent(el);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Text, speech and mark
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
function migrateText(el: Element) {
|
|
375
|
+
renameAttribute(el, "freetext", "free-text");
|
|
376
|
+
renameAttribute(el, "ignorecase", "ignore-case");
|
|
377
|
+
renameAttribute(el, "wrongmessage", "wrong-message");
|
|
378
|
+
// `showsolution` is now the quiz-wide "detailed feedback" option, `value`
|
|
379
|
+
// held a learner's answer.
|
|
380
|
+
removeAttributes(el, "showsolution", "value");
|
|
381
|
+
markAsCurrent(el);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function migrateSpeech(el: Element) {
|
|
385
|
+
removeAttributes(el, "loading", "recording");
|
|
386
|
+
markAsCurrent(el);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
type LegacyRange = { startContainer: string; startOffset: number; endContainer?: string; endOffset: number };
|
|
390
|
+
|
|
391
|
+
function isLegacyRange(value: unknown): value is LegacyRange {
|
|
392
|
+
const range = value as LegacyRange;
|
|
393
|
+
return typeof range?.startContainer === "string" && typeof range?.startOffset === "number";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function evaluateXPath(path: string) {
|
|
397
|
+
try {
|
|
398
|
+
return document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Resolves a v1 range, which addressed its boundaries by document-wide XPath. */
|
|
405
|
+
function resolveLegacyRange(legacy: LegacyRange, scope: Element) {
|
|
406
|
+
const start = evaluateXPath(legacy.startContainer);
|
|
407
|
+
const end = legacy.endContainer ? evaluateXPath(legacy.endContainer) : start;
|
|
408
|
+
if (!start || !end || !scope.contains(start) || !scope.contains(end)) return undefined;
|
|
409
|
+
|
|
410
|
+
const range = document.createRange();
|
|
411
|
+
const clamp = (node: Node, offset: number) =>
|
|
412
|
+
Math.max(
|
|
413
|
+
0,
|
|
414
|
+
Math.min(offset, node.nodeType === Node.TEXT_NODE ? (node as Text).data.length : node.childNodes.length),
|
|
415
|
+
);
|
|
416
|
+
range.setStart(start, clamp(start, legacy.startOffset));
|
|
417
|
+
range.setEnd(end, clamp(end, legacy.endOffset));
|
|
418
|
+
return range;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The word-like segments of `root`, indexed exactly like `<webwriter-mark>` does. */
|
|
422
|
+
function wordSegments(root: Element) {
|
|
423
|
+
const segments: Range[] = [];
|
|
424
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
425
|
+
|
|
426
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
427
|
+
const text = node as Text;
|
|
428
|
+
for (const segment of segmenter.segment(text.data)) {
|
|
429
|
+
if (!segment.isWordLike) continue;
|
|
430
|
+
const range = document.createRange();
|
|
431
|
+
range.setStart(text, segment.index);
|
|
432
|
+
range.setEnd(text, segment.index + segment.segment.length);
|
|
433
|
+
segments.push(range);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return segments;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function rangesOverlap(a: Range, b: Range) {
|
|
441
|
+
return a.compareBoundaryPoints(Range.END_TO_START, b) < 0 && a.compareBoundaryPoints(Range.START_TO_END, b) > 0;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* v1 stored marked text as XPath ranges on the task, v2 stores the indices of
|
|
446
|
+
* the marked words. The ranges are resolved before the content is flattened to
|
|
447
|
+
* plain text, since the XPaths describe the document as it was saved.
|
|
448
|
+
*/
|
|
449
|
+
function migrateMark(el: Element) {
|
|
450
|
+
const legacy = (Array.isArray(getTaskSolution(el)) ? getTaskSolution(el) : []) as unknown[];
|
|
451
|
+
const ranges = legacy
|
|
452
|
+
.filter(isLegacyRange)
|
|
453
|
+
.map(value => resolveLegacyRange(value, el))
|
|
454
|
+
.filter((range): range is Range => range !== undefined);
|
|
455
|
+
|
|
456
|
+
if (legacy.length && !ranges.length) {
|
|
457
|
+
// The XPaths were resolved against the document as it was saved, so they
|
|
458
|
+
// can go stale if the surrounding content moved.
|
|
459
|
+
warn("Could not locate the marked words of a task, they have to be marked again.", el);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// v2's content model is text only. Line breaks become newlines so that the
|
|
463
|
+
// words around them stay separate segments.
|
|
464
|
+
for (const child of Array.from(el.querySelectorAll("br, wbr"))) {
|
|
465
|
+
if (child.localName === "br") child.replaceWith(document.createTextNode("\n"));
|
|
466
|
+
else child.remove();
|
|
467
|
+
}
|
|
468
|
+
for (let child = el.firstElementChild; child; child = el.firstElementChild) {
|
|
469
|
+
child.replaceWith(...Array.from(child.childNodes));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const segments = wordSegments(el);
|
|
473
|
+
const solution = segments
|
|
474
|
+
.map((segment, index) => (ranges.some(range => rangesOverlap(range, segment)) ? index : -1))
|
|
475
|
+
.filter(index => index !== -1);
|
|
476
|
+
|
|
477
|
+
// `highlighting` was an authoring toggle, `value` held a learner's marks.
|
|
478
|
+
removeAttributes(el, "highlighting", "value");
|
|
479
|
+
if (solution.length) setSolution(el, solution);
|
|
480
|
+
markAsCurrent(el);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// Cloze (now gap)
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
function migrateCloze(el: Element) {
|
|
488
|
+
const dragAndDrop = dragAndDropClozes.has(el);
|
|
489
|
+
const gap = retag(el, "webwriter-gap");
|
|
490
|
+
if (dragAndDrop) gap.setAttribute("mode", "drag-and-drop");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function migrateClozeGap(el: Element) {
|
|
494
|
+
if (el.hasAttribute("showoptions")) {
|
|
495
|
+
const cloze = el.closest("webwriter-cloze");
|
|
496
|
+
if (cloze) dragAndDropClozes.add(cloze);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const solutions = parseJson(el.getAttribute("solution"));
|
|
500
|
+
const accepted = Array.isArray(solutions) ? solutions.filter(value => typeof value === "string") : [];
|
|
501
|
+
if (accepted.length > 1) {
|
|
502
|
+
warn(`A gap accepted ${accepted.length} answers, v2 keeps only "${accepted[0]}".`, el);
|
|
503
|
+
}
|
|
504
|
+
if (parseJson(el.getAttribute("distraction"))) {
|
|
505
|
+
warn("A gap had distractor options, which v2 does not support. They were dropped.", el);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const item = createWidget("webwriter-gap-item", el.id);
|
|
509
|
+
item.textContent = accepted[0] ?? "";
|
|
510
|
+
el.replaceWith(item);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// Pairing
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
/** Keeps only complete pairs, which are the only thing v2 can render. */
|
|
518
|
+
function applyPairingSolution(el: Element, legacy: unknown[]) {
|
|
519
|
+
const items = Array.from(el.querySelectorAll(":scope > webwriter-pairing-item"));
|
|
520
|
+
const ids = new Set(items.map(item => item.id));
|
|
521
|
+
const pairs = legacy.filter(
|
|
522
|
+
(entry): entry is [string, string] =>
|
|
523
|
+
Array.isArray(entry) && entry.length === 2 && entry.every(id => typeof id === "string" && ids.has(id)),
|
|
524
|
+
);
|
|
525
|
+
const paired = new Set(pairs.flat());
|
|
526
|
+
|
|
527
|
+
for (const item of items) {
|
|
528
|
+
if (paired.has(item.id)) continue;
|
|
529
|
+
warn("Removed a pairing item that was not part of a pair, which v2 cannot represent.", item);
|
|
530
|
+
item.remove();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
setSolution(el, pairs);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function migratePairing(el: Element) {
|
|
537
|
+
el.setAttribute("mode", el.getAttribute("mode") === "memory" ? "memory" : "pairing");
|
|
538
|
+
const legacy = parseJson(el.getAttribute("solution")) ?? getTaskSolution(el);
|
|
539
|
+
applyPairingSolution(el, Array.isArray(legacy) ? legacy : []);
|
|
540
|
+
markAsCurrent(el);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function migratePairingItem(el: Element) {
|
|
544
|
+
removeAttributes(el, "draggable", "droppreview");
|
|
545
|
+
|
|
546
|
+
// v2 allows a single `<p>` or `<picture>`, v1 allowed any flow content.
|
|
547
|
+
const picture = el.querySelector("picture");
|
|
548
|
+
if (picture) {
|
|
549
|
+
el.replaceChildren(picture);
|
|
550
|
+
} else {
|
|
551
|
+
const paragraph = document.createElement("p");
|
|
552
|
+
paragraph.append(...Array.from(el.childNodes));
|
|
553
|
+
for (const child of Array.from(paragraph.children)) {
|
|
554
|
+
if (child.localName === "p" || child.localName === "div") {
|
|
555
|
+
child.replaceWith(...Array.from(child.childNodes));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
el.replaceChildren(paragraph);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
markAsCurrent(el);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// Entry point
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
function migrateElement(el: Element) {
|
|
569
|
+
switch (el.localName) {
|
|
570
|
+
case "webwriter-quiz":
|
|
571
|
+
return migrateQuiz(el);
|
|
572
|
+
case "webwriter-task":
|
|
573
|
+
return migrateTask(el);
|
|
574
|
+
case "webwriter-task-prompt":
|
|
575
|
+
return migratePrompt(el);
|
|
576
|
+
case "webwriter-task-hint":
|
|
577
|
+
return migrateHint(el);
|
|
578
|
+
case "webwriter-task-explainer":
|
|
579
|
+
return migrateExplainer(el);
|
|
580
|
+
case "webwriter-choice":
|
|
581
|
+
return migrateChoice(el);
|
|
582
|
+
case "webwriter-choice-item":
|
|
583
|
+
return migrateChoiceItem(el);
|
|
584
|
+
case "webwriter-order":
|
|
585
|
+
return migrateOrder(el);
|
|
586
|
+
case "webwriter-order-item":
|
|
587
|
+
return migrateOrderItem(el);
|
|
588
|
+
case "webwriter-text":
|
|
589
|
+
return migrateText(el);
|
|
590
|
+
case "webwriter-speech":
|
|
591
|
+
return migrateSpeech(el);
|
|
592
|
+
case "webwriter-mark":
|
|
593
|
+
return migrateMark(el);
|
|
594
|
+
case "webwriter-cloze":
|
|
595
|
+
return migrateCloze(el);
|
|
596
|
+
case "webwriter-cloze-gap":
|
|
597
|
+
return migrateClozeGap(el);
|
|
598
|
+
case "webwriter-pairing":
|
|
599
|
+
return migratePairing(el);
|
|
600
|
+
case "webwriter-pairing-item":
|
|
601
|
+
return migratePairingItem(el);
|
|
602
|
+
default:
|
|
603
|
+
return markAsCurrent(el);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
document.addEventListener("migrate", event => {
|
|
608
|
+
const el = event.target as Element | null;
|
|
609
|
+
if (!el?.classList) return;
|
|
610
|
+
|
|
611
|
+
const classes = Array.from(el.classList);
|
|
612
|
+
if (classes.find(cls => cls.startsWith("ww-pkg-"))?.slice("ww-pkg-".length) !== packageName) return;
|
|
613
|
+
|
|
614
|
+
const major = Number.parseInt(classes.find(cls => cls.startsWith("ww-v"))?.slice("ww-v".length) ?? "", 10);
|
|
615
|
+
if (!Number.isFinite(major) || major >= CURRENT_MAJOR_VERSION) return;
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
migrateElement(el);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
warn(`Could not migrate a <${el.localName}>, it may display incorrectly.`, el, error);
|
|
621
|
+
}
|
|
622
|
+
});
|