@longsightgroup/qti3-migrator 0.9.5
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.md +21 -0
- package/README.md +13 -0
- package/dist/diagnostics.d.ts +7 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +7 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +262 -0
- package/dist/index.js.map +1 -0
- package/dist/options.d.ts +3 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +7 -0
- package/dist/options.js.map +1 -0
- package/dist/qti12-item.d.ts +7 -0
- package/dist/qti12-item.d.ts.map +1 -0
- package/dist/qti12-item.js +393 -0
- package/dist/qti12-item.js.map +1 -0
- package/dist/qti2-body.d.ts +11 -0
- package/dist/qti2-body.d.ts.map +1 -0
- package/dist/qti2-body.js +90 -0
- package/dist/qti2-body.js.map +1 -0
- package/dist/qti2-choices.d.ts +7 -0
- package/dist/qti2-choices.d.ts.map +1 -0
- package/dist/qti2-choices.js +74 -0
- package/dist/qti2-choices.js.map +1 -0
- package/dist/qti2-graphic.d.ts +10 -0
- package/dist/qti2-graphic.d.ts.map +1 -0
- package/dist/qti2-graphic.js +30 -0
- package/dist/qti2-graphic.js.map +1 -0
- package/dist/qti2-item.d.ts +8 -0
- package/dist/qti2-item.d.ts.map +1 -0
- package/dist/qti2-item.js +494 -0
- package/dist/qti2-item.js.map +1 -0
- package/dist/qti2-response.d.ts +9 -0
- package/dist/qti2-response.d.ts.map +1 -0
- package/dist/qti2-response.js +32 -0
- package/dist/qti2-response.js.map +1 -0
- package/dist/source.d.ts +30 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +197 -0
- package/dist/source.js.map +1 -0
- package/dist/text.d.ts +5 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +19 -0
- package/dist/text.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/xml.d.ts +16 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +101 -0
- package/dist/xml.js.map +1 -0
- package/package.json +56 -0
- package/src/diagnostics.ts +21 -0
- package/src/index.ts +393 -0
- package/src/options.ts +8 -0
- package/src/qti12-item.ts +507 -0
- package/src/qti2-body.ts +138 -0
- package/src/qti2-choices.ts +96 -0
- package/src/qti2-graphic.ts +32 -0
- package/src/qti2-item.ts +650 -0
- package/src/qti2-response.ts +41 -0
- package/src/source.ts +251 -0
- package/src/text.ts +21 -0
- package/src/types.ts +73 -0
- package/src/xml.ts +119 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import {
|
|
2
|
+
qti3TrustedXmlFragment,
|
|
3
|
+
type Qti3AuthoringChoice,
|
|
4
|
+
type Qti3AuthoringItem,
|
|
5
|
+
type Qti3MatchChoice,
|
|
6
|
+
} from "@longsightgroup/qti3-writer";
|
|
7
|
+
import { diagnostic } from "./diagnostics.js";
|
|
8
|
+
import { escapeText, normalizeIdentifier } from "./text.js";
|
|
9
|
+
import type { QtiMigrationDiagnostic, ResolvedQtiMigrationOptions } from "./types.js";
|
|
10
|
+
import {
|
|
11
|
+
attr,
|
|
12
|
+
childElements,
|
|
13
|
+
findAllDescendantsByLocalName,
|
|
14
|
+
findDescendantByLocalName,
|
|
15
|
+
localName,
|
|
16
|
+
parseXml,
|
|
17
|
+
serializeChildren,
|
|
18
|
+
textOf,
|
|
19
|
+
type XmlElement,
|
|
20
|
+
} from "./xml.js";
|
|
21
|
+
|
|
22
|
+
export function migrateQti12Xml(
|
|
23
|
+
xml: string,
|
|
24
|
+
path: string,
|
|
25
|
+
options: ResolvedQtiMigrationOptions,
|
|
26
|
+
): readonly {
|
|
27
|
+
authoringItem?: Qti3AuthoringItem | undefined;
|
|
28
|
+
diagnostics: readonly QtiMigrationDiagnostic[];
|
|
29
|
+
}[] {
|
|
30
|
+
const doc = parseXml(xml, path);
|
|
31
|
+
const root = doc.documentElement;
|
|
32
|
+
const itemElements =
|
|
33
|
+
localName(root) === "item" ? [root] : findAllDescendantsByLocalName(root, "item");
|
|
34
|
+
if (!itemElements.length) {
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
diagnostics: [
|
|
38
|
+
diagnostic("qti12_item_missing", "error", "No QTI 1.2 item elements found.", {
|
|
39
|
+
path,
|
|
40
|
+
sourceFormat: "qti12",
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
return itemElements.map((item, index) => migrateQti12ItemElement(item, index, path, options));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function migrateQti12ItemElement(
|
|
50
|
+
item: XmlElement,
|
|
51
|
+
index: number,
|
|
52
|
+
path: string,
|
|
53
|
+
options: ResolvedQtiMigrationOptions,
|
|
54
|
+
): {
|
|
55
|
+
authoringItem?: Qti3AuthoringItem | undefined;
|
|
56
|
+
diagnostics: readonly QtiMigrationDiagnostic[];
|
|
57
|
+
} {
|
|
58
|
+
const identifier = normalizeIdentifier(attr(item, "ident"), `ITEM_${index + 1}`);
|
|
59
|
+
const title = attr(item, "title")?.trim() || `Item ${index + 1}`;
|
|
60
|
+
const presentation = findDescendantByLocalName(item, "presentation");
|
|
61
|
+
const bodyHtml = qti3TrustedXmlFragment(presentation ? materialHtml(presentation) : "<p></p>");
|
|
62
|
+
const responseLids = findAllDescendantsByLocalName(item, "response_lid");
|
|
63
|
+
const responseStrs = findAllDescendantsByLocalName(item, "response_str");
|
|
64
|
+
const responseNums = findAllDescendantsByLocalName(item, "response_num");
|
|
65
|
+
const responseGrps = findAllDescendantsByLocalName(item, "response_grp");
|
|
66
|
+
const correct = correctEntries(item);
|
|
67
|
+
const itemType = (attr(item, "title") ?? "").toLowerCase();
|
|
68
|
+
|
|
69
|
+
if (itemType.includes("essay")) {
|
|
70
|
+
return {
|
|
71
|
+
authoringItem: {
|
|
72
|
+
interactionType: "extendedText",
|
|
73
|
+
identifier,
|
|
74
|
+
title,
|
|
75
|
+
bodyHtml,
|
|
76
|
+
responseIdentifier: "RESPONSE",
|
|
77
|
+
responseBaseType: "string",
|
|
78
|
+
responseCardinality: "single",
|
|
79
|
+
expectedLines: 8,
|
|
80
|
+
},
|
|
81
|
+
diagnostics: [],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hotspotResponse = responseLids.find((response) =>
|
|
86
|
+
findDescendantByLocalName(response, "render_hotspot"),
|
|
87
|
+
);
|
|
88
|
+
if (hotspotResponse) {
|
|
89
|
+
return {
|
|
90
|
+
...mapQti12Hotspot(identifier, title, hotspotResponse, bodyHtml, correct, options),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const groupedChoice = responseGrps.find((response) =>
|
|
95
|
+
findDescendantByLocalName(response, "render_choice"),
|
|
96
|
+
);
|
|
97
|
+
if (groupedChoice) {
|
|
98
|
+
return {
|
|
99
|
+
...mapQti12Associate(identifier, title, groupedChoice, bodyHtml, correct, options),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const choiceResponses = responseLids.filter((response) =>
|
|
104
|
+
findDescendantByLocalName(response, "render_choice"),
|
|
105
|
+
);
|
|
106
|
+
if (choiceResponses.length > 1) {
|
|
107
|
+
return {
|
|
108
|
+
...mapQti12CanvasMatch(identifier, title, choiceResponses, bodyHtml, correct, options),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const choiceResponse = choiceResponses[0];
|
|
113
|
+
if (choiceResponse) {
|
|
114
|
+
return {
|
|
115
|
+
...mapQti12Choice(identifier, title, choiceResponse, bodyHtml, correct, options),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const fibResponse = [...responseLids, ...responseStrs, ...responseNums].find((response) =>
|
|
120
|
+
findDescendantByLocalName(response, "render_fib"),
|
|
121
|
+
);
|
|
122
|
+
if (fibResponse) {
|
|
123
|
+
return {
|
|
124
|
+
...mapQti12TextEntry(identifier, title, fibResponse, presentation, correct, options),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
diagnostics: [
|
|
130
|
+
diagnostic("qti12_interaction_unsupported", "error", "Unsupported QTI 1.2 interaction.", {
|
|
131
|
+
path,
|
|
132
|
+
sourceFormat: "qti12",
|
|
133
|
+
}),
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function mapQti12CanvasMatch(
|
|
139
|
+
identifier: string,
|
|
140
|
+
title: string,
|
|
141
|
+
responses: readonly XmlElement[],
|
|
142
|
+
bodyHtml: ReturnType<typeof qti3TrustedXmlFragment>,
|
|
143
|
+
correct: ReadonlyMap<string, string[]>,
|
|
144
|
+
options: ResolvedQtiMigrationOptions,
|
|
145
|
+
): {
|
|
146
|
+
readonly authoringItem?: Qti3AuthoringItem | undefined;
|
|
147
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
148
|
+
} {
|
|
149
|
+
const sources: Qti3MatchChoice[] = responses.map((response, index) => {
|
|
150
|
+
const responseIdentifier = normalizeIdentifier(attr(response, "ident"), `SOURCE_${index + 1}`);
|
|
151
|
+
const material = findDescendantByLocalName(response, "material");
|
|
152
|
+
return {
|
|
153
|
+
identifier: responseIdentifier,
|
|
154
|
+
contentHtml: qti3TrustedXmlFragment(material ? materialHtml(material) : responseIdentifier),
|
|
155
|
+
text: textOf(material) || responseIdentifier,
|
|
156
|
+
matchMax: 1,
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
const targets = canvasMatchTargets(responses);
|
|
160
|
+
const targetIdentifiers = new Set(targets.map((target) => target.identifier));
|
|
161
|
+
const correctResponse = sources.flatMap((source) => {
|
|
162
|
+
const targetIdentifier = (correct.get(source.identifier) ?? [])
|
|
163
|
+
.map((entry) => normalizeIdentifier(entry))
|
|
164
|
+
.find((entry) => targetIdentifiers.has(entry));
|
|
165
|
+
return targetIdentifier ? [{ sourceIdentifier: source.identifier, targetIdentifier }] : [];
|
|
166
|
+
});
|
|
167
|
+
const repair = repairOrError({
|
|
168
|
+
needed: correctResponse.length !== sources.length,
|
|
169
|
+
options,
|
|
170
|
+
code: "qti12_canvas_match_correct_response_incomplete",
|
|
171
|
+
message: "Canvas QTI 1.2 matching item has incomplete or invalid correct pairs.",
|
|
172
|
+
repairMessage:
|
|
173
|
+
"Canvas QTI 1.2 matching item has incomplete correct pairs; migrating available pairs for review.",
|
|
174
|
+
});
|
|
175
|
+
if (repair.blocked) return { diagnostics: repair.diagnostics };
|
|
176
|
+
return {
|
|
177
|
+
authoringItem: {
|
|
178
|
+
interactionType: "match",
|
|
179
|
+
identifier,
|
|
180
|
+
title,
|
|
181
|
+
bodyHtml,
|
|
182
|
+
responseIdentifier: "RESPONSE",
|
|
183
|
+
sources,
|
|
184
|
+
targets,
|
|
185
|
+
correctResponse,
|
|
186
|
+
shuffle: false,
|
|
187
|
+
},
|
|
188
|
+
diagnostics: repair.diagnostics,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function canvasMatchTargets(responses: readonly XmlElement[]): Qti3MatchChoice[] {
|
|
193
|
+
const targets: Qti3MatchChoice[] = [];
|
|
194
|
+
const seen = new Set<string>();
|
|
195
|
+
for (const response of responses) {
|
|
196
|
+
const renderChoice = findDescendantByLocalName(response, "render_choice");
|
|
197
|
+
const labels = renderChoice
|
|
198
|
+
? findAllDescendantsByLocalName(renderChoice, "response_label")
|
|
199
|
+
: [];
|
|
200
|
+
for (const [index, label] of labels.entries()) {
|
|
201
|
+
const identifier = normalizeIdentifier(attr(label, "ident"), `TARGET_${index + 1}`);
|
|
202
|
+
if (seen.has(identifier)) continue;
|
|
203
|
+
seen.add(identifier);
|
|
204
|
+
targets.push({
|
|
205
|
+
identifier,
|
|
206
|
+
contentHtml: qti3TrustedXmlFragment(materialHtml(label)),
|
|
207
|
+
text: textOf(label) || identifier,
|
|
208
|
+
matchMax: 1,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return targets;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function mapQti12Choice(
|
|
216
|
+
identifier: string,
|
|
217
|
+
title: string,
|
|
218
|
+
response: XmlElement,
|
|
219
|
+
bodyHtml: ReturnType<typeof qti3TrustedXmlFragment>,
|
|
220
|
+
correct: ReadonlyMap<string, string[]>,
|
|
221
|
+
options: ResolvedQtiMigrationOptions,
|
|
222
|
+
): {
|
|
223
|
+
readonly authoringItem?: Qti3AuthoringItem | undefined;
|
|
224
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
225
|
+
} {
|
|
226
|
+
const responseIdentifier = normalizeIdentifier(attr(response, "ident"), "RESPONSE");
|
|
227
|
+
const choices = responseChoices(response, "CHOICE");
|
|
228
|
+
const rawCorrect = correct.get(responseIdentifier) ?? [];
|
|
229
|
+
const correctResponse = (correct.get(responseIdentifier) ?? [])
|
|
230
|
+
.map((value) => normalizeIdentifier(value))
|
|
231
|
+
.filter((value) => choices.some((choice) => choice.identifier === value));
|
|
232
|
+
const repair = repairOrError({
|
|
233
|
+
needed: !correctResponse.length,
|
|
234
|
+
options,
|
|
235
|
+
code: "qti12_choice_correct_response_missing",
|
|
236
|
+
message: "QTI 1.2 choice response has no valid correct response.",
|
|
237
|
+
repairMessage: rawCorrect.length
|
|
238
|
+
? "QTI 1.2 choice correct response referenced unknown labels; using the first declared choice."
|
|
239
|
+
: "QTI 1.2 choice response did not declare a correct response; using the first declared choice.",
|
|
240
|
+
});
|
|
241
|
+
if (repair.blocked) return { diagnostics: repair.diagnostics };
|
|
242
|
+
const isMultiple =
|
|
243
|
+
(attr(response, "rcardinality") ?? "").toLowerCase() === "multiple" ||
|
|
244
|
+
correctResponse.length > 1;
|
|
245
|
+
return {
|
|
246
|
+
authoringItem: {
|
|
247
|
+
interactionType: "choice",
|
|
248
|
+
identifier,
|
|
249
|
+
title,
|
|
250
|
+
bodyHtml,
|
|
251
|
+
responseIdentifier,
|
|
252
|
+
responseCardinality: isMultiple ? "multiple" : "single",
|
|
253
|
+
choices,
|
|
254
|
+
correctResponse: correctResponse.length
|
|
255
|
+
? correctResponse
|
|
256
|
+
: choices.slice(0, 1).map((choice) => choice.identifier),
|
|
257
|
+
maxChoices: isMultiple ? undefined : 1,
|
|
258
|
+
},
|
|
259
|
+
diagnostics: repair.diagnostics,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function mapQti12Associate(
|
|
264
|
+
identifier: string,
|
|
265
|
+
title: string,
|
|
266
|
+
response: XmlElement,
|
|
267
|
+
bodyHtml: ReturnType<typeof qti3TrustedXmlFragment>,
|
|
268
|
+
correct: ReadonlyMap<string, string[]>,
|
|
269
|
+
options: ResolvedQtiMigrationOptions,
|
|
270
|
+
): {
|
|
271
|
+
readonly authoringItem?: Qti3AuthoringItem | undefined;
|
|
272
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
273
|
+
} {
|
|
274
|
+
const responseIdentifier = normalizeIdentifier(attr(response, "ident"), "RESPONSE");
|
|
275
|
+
const choices = responseChoices(response, "CHOICE").map((choice) => ({ ...choice, matchMax: 2 }));
|
|
276
|
+
const pairs = (correct.get(responseIdentifier) ?? [])
|
|
277
|
+
.map((entry) => {
|
|
278
|
+
const [sourceIdentifier = "", targetIdentifier = ""] = entry.split(/\s+/);
|
|
279
|
+
return {
|
|
280
|
+
sourceIdentifier: normalizeIdentifier(sourceIdentifier),
|
|
281
|
+
targetIdentifier: normalizeIdentifier(targetIdentifier),
|
|
282
|
+
};
|
|
283
|
+
})
|
|
284
|
+
.filter((pair) => pair.sourceIdentifier && pair.targetIdentifier);
|
|
285
|
+
const repair = repairOrError({
|
|
286
|
+
needed: !pairs.length,
|
|
287
|
+
options,
|
|
288
|
+
code: "qti12_associate_correct_response_missing",
|
|
289
|
+
message: "QTI 1.2 associate response has no valid correct pair.",
|
|
290
|
+
repairMessage:
|
|
291
|
+
"QTI 1.2 associate response did not declare a valid pair; using the first two choices.",
|
|
292
|
+
});
|
|
293
|
+
if (repair.blocked) return { diagnostics: repair.diagnostics };
|
|
294
|
+
return {
|
|
295
|
+
authoringItem: {
|
|
296
|
+
interactionType: "associate",
|
|
297
|
+
identifier,
|
|
298
|
+
title,
|
|
299
|
+
bodyHtml,
|
|
300
|
+
responseIdentifier,
|
|
301
|
+
choices,
|
|
302
|
+
correctResponse: pairs.length
|
|
303
|
+
? pairs
|
|
304
|
+
: choices.length >= 2
|
|
305
|
+
? [{ sourceIdentifier: choices[0]!.identifier, targetIdentifier: choices[1]!.identifier }]
|
|
306
|
+
: [],
|
|
307
|
+
},
|
|
308
|
+
diagnostics: repair.diagnostics,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function mapQti12TextEntry(
|
|
313
|
+
identifier: string,
|
|
314
|
+
title: string,
|
|
315
|
+
response: XmlElement,
|
|
316
|
+
presentation: XmlElement | null,
|
|
317
|
+
correct: ReadonlyMap<string, string[]>,
|
|
318
|
+
options: ResolvedQtiMigrationOptions,
|
|
319
|
+
): {
|
|
320
|
+
readonly authoringItem?: Qti3AuthoringItem | undefined;
|
|
321
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
322
|
+
} {
|
|
323
|
+
const responseIdentifier = normalizeIdentifier(attr(response, "ident"), "RESPONSE");
|
|
324
|
+
const values = correct.get(responseIdentifier) ?? [];
|
|
325
|
+
const repair = repairOrError({
|
|
326
|
+
needed: !values.length,
|
|
327
|
+
options,
|
|
328
|
+
code: "qti12_text_entry_correct_response_missing",
|
|
329
|
+
message: "QTI 1.2 text entry response has no correct text value.",
|
|
330
|
+
repairMessage:
|
|
331
|
+
"QTI 1.2 text entry response did not declare a correct value; using an empty answer.",
|
|
332
|
+
});
|
|
333
|
+
if (repair.blocked) return { diagnostics: repair.diagnostics };
|
|
334
|
+
return {
|
|
335
|
+
authoringItem: {
|
|
336
|
+
interactionType: "textEntry",
|
|
337
|
+
identifier,
|
|
338
|
+
title,
|
|
339
|
+
bodyHtml: qti3TrustedXmlFragment(
|
|
340
|
+
`${presentation ? materialHtml(presentation) : "<p></p>"}<p><qti-text-entry-interaction response-identifier="${escapeText(responseIdentifier)}"/></p>`,
|
|
341
|
+
),
|
|
342
|
+
responses: [
|
|
343
|
+
{
|
|
344
|
+
responseIdentifier,
|
|
345
|
+
answers: values.length
|
|
346
|
+
? values.map((value) => ({ value, score: 1, caseSensitive: false }))
|
|
347
|
+
: [{ value: "", score: 1, caseSensitive: false }],
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
diagnostics: repair.diagnostics,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function mapQti12Hotspot(
|
|
356
|
+
identifier: string,
|
|
357
|
+
title: string,
|
|
358
|
+
response: XmlElement,
|
|
359
|
+
bodyHtml: ReturnType<typeof qti3TrustedXmlFragment>,
|
|
360
|
+
correct: ReadonlyMap<string, string[]>,
|
|
361
|
+
options: ResolvedQtiMigrationOptions,
|
|
362
|
+
): {
|
|
363
|
+
readonly authoringItem?: Qti3AuthoringItem | undefined;
|
|
364
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
365
|
+
} {
|
|
366
|
+
const responseIdentifier = normalizeIdentifier(attr(response, "ident"), "RESPONSE");
|
|
367
|
+
const labels = findAllDescendantsByLocalName(response, "response_label");
|
|
368
|
+
const choices = labels.map((label, index) => ({
|
|
369
|
+
identifier: normalizeIdentifier(attr(label, "ident"), `H${index + 1}`),
|
|
370
|
+
shape: qti12AreaToShape(attr(label, "rarea")),
|
|
371
|
+
coords: attr(label, "coords") ?? attr(label, "xy") ?? "0,0,1,1",
|
|
372
|
+
}));
|
|
373
|
+
const dimensions = inferImageDimensions(choices.map((choice) => choice.coords));
|
|
374
|
+
const correctResponse = (correct.get(responseIdentifier) ?? []).map((value) =>
|
|
375
|
+
normalizeIdentifier(value),
|
|
376
|
+
);
|
|
377
|
+
const correctRepair = repairOrError({
|
|
378
|
+
needed: !correctResponse.length,
|
|
379
|
+
options,
|
|
380
|
+
code: "qti12_hotspot_correct_response_missing",
|
|
381
|
+
message: "QTI 1.2 hotspot response has no correct hotspot identifier.",
|
|
382
|
+
repairMessage:
|
|
383
|
+
"QTI 1.2 hotspot response did not declare a correct hotspot; using the first hotspot.",
|
|
384
|
+
});
|
|
385
|
+
if (correctRepair.blocked) return { diagnostics: correctRepair.diagnostics };
|
|
386
|
+
const imageRepair = repairOrError({
|
|
387
|
+
needed: true,
|
|
388
|
+
options,
|
|
389
|
+
code: "qti12_hotspot_image_missing",
|
|
390
|
+
message: "QTI 1.2 hotspot migration could not identify the source image.",
|
|
391
|
+
repairMessage:
|
|
392
|
+
"QTI 1.2 hotspot source image was not identified; using review placeholder image.png.",
|
|
393
|
+
});
|
|
394
|
+
if (imageRepair.blocked) return { diagnostics: imageRepair.diagnostics };
|
|
395
|
+
return {
|
|
396
|
+
authoringItem: {
|
|
397
|
+
interactionType: "hotspot",
|
|
398
|
+
identifier,
|
|
399
|
+
title,
|
|
400
|
+
bodyHtml,
|
|
401
|
+
responseIdentifier,
|
|
402
|
+
object: {
|
|
403
|
+
data: "image.png",
|
|
404
|
+
alt: "Image",
|
|
405
|
+
width: dimensions.width,
|
|
406
|
+
height: dimensions.height,
|
|
407
|
+
},
|
|
408
|
+
choices,
|
|
409
|
+
correctResponse: correctResponse.length
|
|
410
|
+
? correctResponse
|
|
411
|
+
: choices.slice(0, 1).map((choice) => choice.identifier),
|
|
412
|
+
maxChoices: 1,
|
|
413
|
+
},
|
|
414
|
+
diagnostics: [...correctRepair.diagnostics, ...imageRepair.diagnostics],
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function responseChoices(response: XmlElement, prefix: string): Qti3AuthoringChoice[] {
|
|
419
|
+
return findAllDescendantsByLocalName(response, "response_label").map((label, index) => ({
|
|
420
|
+
identifier: normalizeIdentifier(attr(label, "ident"), `${prefix}_${index + 1}`),
|
|
421
|
+
contentHtml: qti3TrustedXmlFragment(materialHtml(label)),
|
|
422
|
+
text: textOf(label) || undefined,
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function materialHtml(root: XmlElement): string {
|
|
427
|
+
const materials = findAllDescendantsByLocalName(root, "material");
|
|
428
|
+
if (!materials.length) return serializeChildren(root).trim() || "<p></p>";
|
|
429
|
+
return materials
|
|
430
|
+
.map((material) => {
|
|
431
|
+
const mattext = findDescendantByLocalName(material, "mattext");
|
|
432
|
+
if (mattext) return `<p>${serializeChildren(mattext) || escapeText(textOf(mattext))}</p>`;
|
|
433
|
+
const matimage = findDescendantByLocalName(material, "matimage");
|
|
434
|
+
if (matimage) {
|
|
435
|
+
const src = attr(matimage, "uri") ?? "";
|
|
436
|
+
return `<p><img src="${escapeText(src)}"/></p>`;
|
|
437
|
+
}
|
|
438
|
+
return `<p>${escapeText(textOf(material))}</p>`;
|
|
439
|
+
})
|
|
440
|
+
.join("\n");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function correctEntries(item: XmlElement): Map<string, string[]> {
|
|
444
|
+
const out = new Map<string, string[]>();
|
|
445
|
+
for (const condition of findAllDescendantsByLocalName(item, "respcondition")) {
|
|
446
|
+
const varequal = findAllDescendantsByLocalName(condition, "varequal");
|
|
447
|
+
for (const value of varequal) {
|
|
448
|
+
const responseIdentifier = normalizeIdentifier(attr(value, "respident"), "RESPONSE");
|
|
449
|
+
const values = out.get(responseIdentifier) ?? [];
|
|
450
|
+
values.push(textOf(value));
|
|
451
|
+
out.set(responseIdentifier, values);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
for (const response of findAllDescendantsByLocalName(item, "response_lid")) {
|
|
455
|
+
const responseIdentifier = normalizeIdentifier(attr(response, "ident"), "RESPONSE");
|
|
456
|
+
if (out.has(responseIdentifier)) continue;
|
|
457
|
+
const labels = childElements(
|
|
458
|
+
findDescendantByLocalName(response, "render_choice") ?? response,
|
|
459
|
+
).filter((child) => localName(child) === "response_label");
|
|
460
|
+
const correct = labels.find((label) => attr(label, "rshuffle") === "No");
|
|
461
|
+
if (correct) out.set(responseIdentifier, [attr(correct, "ident") ?? ""]);
|
|
462
|
+
}
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function qti12AreaToShape(value: string | null): "circle" | "rect" | "poly" {
|
|
467
|
+
const normalized = value?.toLowerCase();
|
|
468
|
+
if (normalized === "ellipse") return "circle";
|
|
469
|
+
if (normalized === "bounded") return "poly";
|
|
470
|
+
return "rect";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function inferImageDimensions(coords: readonly string[]): { width: number; height: number } {
|
|
474
|
+
let maxX = 1;
|
|
475
|
+
let maxY = 1;
|
|
476
|
+
for (const entry of coords) {
|
|
477
|
+
const values = entry
|
|
478
|
+
.split(/[\s,]+/)
|
|
479
|
+
.map(Number)
|
|
480
|
+
.filter(Number.isFinite);
|
|
481
|
+
for (let index = 0; index < values.length; index += 2) {
|
|
482
|
+
maxX = Math.max(maxX, values[index] ?? 1);
|
|
483
|
+
maxY = Math.max(maxY, values[index + 1] ?? 1);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return { width: Math.ceil(maxX), height: Math.ceil(maxY) };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function repairOrError(input: {
|
|
490
|
+
readonly needed: boolean;
|
|
491
|
+
readonly options: ResolvedQtiMigrationOptions;
|
|
492
|
+
readonly code: string;
|
|
493
|
+
readonly message: string;
|
|
494
|
+
readonly repairMessage: string;
|
|
495
|
+
}): { readonly blocked: boolean; readonly diagnostics: readonly QtiMigrationDiagnostic[] } {
|
|
496
|
+
if (!input.needed) return { blocked: false, diagnostics: [] };
|
|
497
|
+
if (input.options.repairPolicy === "safe") {
|
|
498
|
+
return {
|
|
499
|
+
blocked: false,
|
|
500
|
+
diagnostics: [diagnostic(`${input.code}_repaired`, "warning", input.repairMessage)],
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
blocked: true,
|
|
505
|
+
diagnostics: [diagnostic(input.code, "error", input.message)],
|
|
506
|
+
};
|
|
507
|
+
}
|
package/src/qti2-body.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { qti3TrustedXmlFragment } from "@longsightgroup/qti3-writer";
|
|
2
|
+
import { escapeText, normalizeIdentifier } from "./text.js";
|
|
3
|
+
import {
|
|
4
|
+
attr,
|
|
5
|
+
childElements,
|
|
6
|
+
findAllDescendantsByAnyLocalName,
|
|
7
|
+
findAllDescendantsByLocalName,
|
|
8
|
+
findDescendantByLocalName,
|
|
9
|
+
isXmlElement,
|
|
10
|
+
localName,
|
|
11
|
+
serializeNode,
|
|
12
|
+
type XmlElement,
|
|
13
|
+
type XmlNode,
|
|
14
|
+
} from "./xml.js";
|
|
15
|
+
|
|
16
|
+
export function prompt(
|
|
17
|
+
interaction: XmlElement,
|
|
18
|
+
): ReturnType<typeof qti3TrustedXmlFragment> | undefined {
|
|
19
|
+
const promptElement = findDescendantByLocalName(interaction, "prompt");
|
|
20
|
+
const html = promptElement ? serializeChildrenReplacing(promptElement, new Map()).trim() : "";
|
|
21
|
+
return html ? trusted(html) : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function bodyWithoutInteraction(
|
|
25
|
+
body: XmlElement,
|
|
26
|
+
interaction: XmlElement,
|
|
27
|
+
): ReturnType<typeof qti3TrustedXmlFragment> {
|
|
28
|
+
return trusted(
|
|
29
|
+
serializeChildrenReplacing(body, new Map([[interaction, ""]])).trim() || "<p></p>",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function bodyWithInlineChoicePlaceholders(
|
|
34
|
+
body: XmlElement,
|
|
35
|
+
interactions: readonly XmlElement[],
|
|
36
|
+
responseIdentifierFor: (interaction: XmlElement, fallback?: string) => string,
|
|
37
|
+
): ReturnType<typeof qti3TrustedXmlFragment> {
|
|
38
|
+
const replacements = new Map<XmlElement, string>();
|
|
39
|
+
for (const [index, interaction] of interactions.entries()) {
|
|
40
|
+
const responseIdentifier = responseIdentifierFor(interaction, `RESPONSE_${index + 1}`);
|
|
41
|
+
replacements.set(
|
|
42
|
+
interaction,
|
|
43
|
+
`<qti-inline-choice-interaction response-identifier="${escapeText(responseIdentifier)}"/>`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return trusted(serializeChildrenReplacing(body, replacements));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function bodyWithTextEntryPlaceholders(
|
|
50
|
+
body: XmlElement,
|
|
51
|
+
interactions: readonly XmlElement[],
|
|
52
|
+
responseIdentifierFor: (interaction: XmlElement, fallback?: string) => string,
|
|
53
|
+
): ReturnType<typeof qti3TrustedXmlFragment> {
|
|
54
|
+
const replacements = new Map<XmlElement, string>();
|
|
55
|
+
for (const [index, interaction] of interactions.entries()) {
|
|
56
|
+
const responseIdentifier = responseIdentifierFor(interaction, `RESPONSE_${index + 1}`);
|
|
57
|
+
replacements.set(
|
|
58
|
+
interaction,
|
|
59
|
+
`<qti-text-entry-interaction response-identifier="${escapeText(responseIdentifier)}"/>`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return trusted(serializeChildrenReplacing(body, replacements));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function bodyWithHottextPlaceholders(
|
|
66
|
+
interaction: XmlElement,
|
|
67
|
+
hottexts: readonly XmlElement[],
|
|
68
|
+
): ReturnType<typeof qti3TrustedXmlFragment> {
|
|
69
|
+
const replacements = new Map<XmlElement, string>();
|
|
70
|
+
for (const [index, hottext] of hottexts.entries()) {
|
|
71
|
+
const identifier = normalizeIdentifier(attr(hottext, "identifier"), `H${index + 1}`);
|
|
72
|
+
replacements.set(hottext, `<qti-hottext identifier="${escapeText(identifier)}"/>`);
|
|
73
|
+
}
|
|
74
|
+
return trusted(serializeChildrenReplacing(interaction, replacements) || "<p></p>");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function bodyWithGapPlaceholders(
|
|
78
|
+
interaction: XmlElement,
|
|
79
|
+
): ReturnType<typeof qti3TrustedXmlFragment> {
|
|
80
|
+
const replacements = new Map<XmlElement, string>();
|
|
81
|
+
for (const choice of findAllDescendantsByAnyLocalName(interaction, ["gaptext", "gapimg"])) {
|
|
82
|
+
replacements.set(choice, "");
|
|
83
|
+
}
|
|
84
|
+
for (const gap of findAllDescendantsByLocalName(interaction, "gap")) {
|
|
85
|
+
const identifier = normalizeIdentifier(attr(gap, "identifier"), "GAP");
|
|
86
|
+
replacements.set(gap, `<qti-gap identifier="${escapeText(identifier)}"/>`);
|
|
87
|
+
}
|
|
88
|
+
return trusted(serializeChildrenReplacing(interaction, replacements) || "<p></p>");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function trusted(html: string): ReturnType<typeof qti3TrustedXmlFragment> {
|
|
92
|
+
return qti3TrustedXmlFragment(html.trim() || "<p></p>");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function serializeChildrenReplacing(
|
|
96
|
+
element: XmlElement,
|
|
97
|
+
replacements: ReadonlyMap<XmlElement, string>,
|
|
98
|
+
): string {
|
|
99
|
+
let out = "";
|
|
100
|
+
for (let index = 0; index < element.childNodes.length; index += 1) {
|
|
101
|
+
out += serializeReplacing(element.childNodes.item(index), replacements);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function serializeReplacing(node: XmlNode, replacements: ReadonlyMap<XmlElement, string>): string {
|
|
107
|
+
if (!isXmlElement(node)) return serializeNode(node);
|
|
108
|
+
const element = node;
|
|
109
|
+
const replacement = replacements.get(element);
|
|
110
|
+
if (replacement !== undefined) return replacement;
|
|
111
|
+
return `<${element.nodeName}${attributesXml(element)}>${serializeChildrenReplacing(
|
|
112
|
+
element,
|
|
113
|
+
replacements,
|
|
114
|
+
)}</${element.nodeName}>`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function attributesXml(element: XmlElement): string {
|
|
118
|
+
const attrs: string[] = [];
|
|
119
|
+
for (let index = 0; index < element.attributes.length; index += 1) {
|
|
120
|
+
const attribute = element.attributes.item(index);
|
|
121
|
+
if (!attribute) continue;
|
|
122
|
+
if (attribute.name === "xmlns" || attribute.name.startsWith("xmlns:")) continue;
|
|
123
|
+
attrs.push(`${attribute.name}="${escapeText(attribute.value)}"`);
|
|
124
|
+
}
|
|
125
|
+
return attrs.length ? ` ${attrs.join(" ")}` : "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function collectInteractionElements(root: XmlElement): XmlElement[] {
|
|
129
|
+
const out: XmlElement[] = [];
|
|
130
|
+
const walk = (element: XmlElement): void => {
|
|
131
|
+
for (const child of childElements(element)) {
|
|
132
|
+
if (localName(child).endsWith("interaction")) out.push(child);
|
|
133
|
+
walk(child);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
walk(root);
|
|
137
|
+
return out;
|
|
138
|
+
}
|