@conform-ed/qti-xml 0.0.20 → 0.0.21
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/dist/cc-qti/convert-to-v3.d.ts +46 -0
- package/dist/cc-qti/index.d.ts +2 -0
- package/dist/cc-qti/normalize-questestinterop.d.ts +28 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +805 -0
- package/package.json +2 -2
- package/src/cc-qti/convert-to-v3.ts +460 -0
- package/src/cc-qti/index.ts +2 -0
- package/src/cc-qti/normalize-questestinterop.ts +544 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conform-ed/qti-xml",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.21",
|
|
4
4
|
"files": [
|
|
5
5
|
"src",
|
|
6
6
|
"dist"
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typecheck": "tsgo --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@conform-ed/contracts": "0.0.
|
|
25
|
+
"@conform-ed/contracts": "0.0.21",
|
|
26
26
|
"fast-xml-parser": "^5.3.1",
|
|
27
27
|
"fast-xml-validator": "^1.1.1",
|
|
28
28
|
"fflate": "^0.8.3"
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge Common Cartridge QTI ASI 1.2.1 (`questestinterop`) into QTI 3.0.1 ASI XML, so a CC
|
|
3
|
+
* cartridge's assessments/question-banks can be ingested + delivered + scored by the same
|
|
4
|
+
* QTI 3 engine as everything else (ADR-0022). The conversion targets the *normalized* QTI 3
|
|
5
|
+
* document the serializers consume, so the emitted XML is guaranteed to re-validate via
|
|
6
|
+
* `validateQtiXmlContent`.
|
|
7
|
+
*
|
|
8
|
+
* A `questestinterop` is either an `<objectbank>` (→ N standalone items) or an `<assessment>`
|
|
9
|
+
* (→ N items + one `assessmentTest` that references them).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { parseXmlDocument, type QtiXmlElementNode } from "../parse-xml";
|
|
13
|
+
import { asiNamespace } from "../serialize-asi";
|
|
14
|
+
import { serializeQtiDocument } from "../serialize-document";
|
|
15
|
+
import { normalizeQuestestinterop } from "./normalize-questestinterop";
|
|
16
|
+
|
|
17
|
+
const MATCH_CORRECT = "https://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct";
|
|
18
|
+
const MAP_RESPONSE = "https://www.imsglobal.org/question/qti_v3p0/rptemplates/map_response";
|
|
19
|
+
|
|
20
|
+
type Node = Record<string, unknown>;
|
|
21
|
+
type Fragment = string | Node;
|
|
22
|
+
|
|
23
|
+
export type CcQtiInteractionKind = "choice" | "textEntry" | "extendedText";
|
|
24
|
+
|
|
25
|
+
export type CcQtiConvertedItem = {
|
|
26
|
+
identifier: string;
|
|
27
|
+
title: string;
|
|
28
|
+
ccProfile: string | undefined;
|
|
29
|
+
interactionKind: CcQtiInteractionKind;
|
|
30
|
+
xml: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type CcQtiConvertedTest = {
|
|
34
|
+
identifier: string;
|
|
35
|
+
title: string;
|
|
36
|
+
itemIdentifiers: string[];
|
|
37
|
+
xml: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type CcQtiConversionResult =
|
|
41
|
+
| {
|
|
42
|
+
status: "converted";
|
|
43
|
+
source: "assessment" | "objectbank";
|
|
44
|
+
items: CcQtiConvertedItem[];
|
|
45
|
+
test?: CcQtiConvertedTest;
|
|
46
|
+
}
|
|
47
|
+
| { status: "invalid"; issues: string[] };
|
|
48
|
+
|
|
49
|
+
// --- small node accessors (mirrors serialize-asi.ts style) -----------------
|
|
50
|
+
|
|
51
|
+
function asNode(value: unknown): Node {
|
|
52
|
+
return (value ?? {}) as Node;
|
|
53
|
+
}
|
|
54
|
+
function arr(value: unknown): Node[] {
|
|
55
|
+
return Array.isArray(value) ? (value as Node[]) : [];
|
|
56
|
+
}
|
|
57
|
+
function strOf(node: Node, key: string): string | undefined {
|
|
58
|
+
const value = node[key];
|
|
59
|
+
return typeof value === "string" ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* QTI 3 identifiers must match this NCName-ish pattern and be unique. Real cartridges (Canvas,
|
|
64
|
+
* TopKit) use numeric choice/response idents (`42987`) and even reuse one item ident across a
|
|
65
|
+
* whole quiz, so CC idents must be sanitized — consistently, since `correctResponse` values for
|
|
66
|
+
* choice items *are* identifiers and must keep referencing the right choice.
|
|
67
|
+
*/
|
|
68
|
+
const QTI_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9._-]*$/u;
|
|
69
|
+
|
|
70
|
+
function sanitizeIdentifier(raw: string): string {
|
|
71
|
+
if (raw.length > 0 && QTI_IDENTIFIER.test(raw)) return raw;
|
|
72
|
+
const replaced = raw.replace(/[^A-Za-z0-9._-]/gu, "_");
|
|
73
|
+
const prefixed = /^[A-Za-z_]/u.test(replaced) ? replaced : `id_${replaced}`;
|
|
74
|
+
return prefixed.length > 0 ? prefixed : "_";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Sanitize + de-duplicate a list of CC identifiers, preserving order. */
|
|
78
|
+
function assignUniqueIdentifiers(rawIds: string[]): string[] {
|
|
79
|
+
const used = new Set<string>();
|
|
80
|
+
return rawIds.map((rawId) => {
|
|
81
|
+
const base = sanitizeIdentifier(rawId);
|
|
82
|
+
let candidate = base;
|
|
83
|
+
let suffix = 2;
|
|
84
|
+
while (used.has(candidate)) {
|
|
85
|
+
candidate = `${base}-${suffix}`;
|
|
86
|
+
suffix += 1;
|
|
87
|
+
}
|
|
88
|
+
used.add(candidate);
|
|
89
|
+
return candidate;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- content: CC material → QTI 3 content fragments ------------------------
|
|
94
|
+
|
|
95
|
+
function htmlToFragments(html: string): Fragment[] {
|
|
96
|
+
try {
|
|
97
|
+
const root = parseXmlDocument(`<div xmlns="${asiNamespace}">${html}</div>`);
|
|
98
|
+
return mapXmlChildren(root);
|
|
99
|
+
} catch {
|
|
100
|
+
// Non-wellformed fragment: keep the text content rather than failing the conversion.
|
|
101
|
+
return [html];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function mapXmlElement(element: QtiXmlElementNode): Node {
|
|
106
|
+
const children = mapXmlChildren(element);
|
|
107
|
+
const out: Node = { kind: "xml", namespace: asiNamespace, name: element.localName };
|
|
108
|
+
if (Object.keys(element.attributes).length > 0) {
|
|
109
|
+
out["attributes"] = element.attributes;
|
|
110
|
+
}
|
|
111
|
+
if (children.length > 0) {
|
|
112
|
+
out["children"] = children;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mapXmlChildren(element: QtiXmlElementNode): Fragment[] {
|
|
118
|
+
const fragments: Fragment[] = [];
|
|
119
|
+
for (const child of element.children) {
|
|
120
|
+
if (child.type === "text") {
|
|
121
|
+
if (child.value.trim().length > 0) fragments.push(child.value);
|
|
122
|
+
} else {
|
|
123
|
+
fragments.push(mapXmlElement(child));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return fragments;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function mattextFragments(mattext: Node): Fragment[] {
|
|
130
|
+
const value = strOf(mattext, "value") ?? "";
|
|
131
|
+
const texttype = strOf(mattext, "texttype");
|
|
132
|
+
if (texttype && texttype.toLowerCase().includes("html")) {
|
|
133
|
+
return htmlToFragments(value);
|
|
134
|
+
}
|
|
135
|
+
return value.length > 0 ? [value] : [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function materialFragments(material: Node): Fragment[] {
|
|
139
|
+
return arr(material["children"]).flatMap((child) => {
|
|
140
|
+
const kind = strOf(child, "kind");
|
|
141
|
+
if (kind === "mattext") return mattextFragments(child);
|
|
142
|
+
if (kind === "matbreak") return [{ kind: "xml", namespace: asiNamespace, name: "br" } as Node];
|
|
143
|
+
return [];
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Stem materials → itemBody block content; inline-only material is wrapped in a <p>. */
|
|
148
|
+
function stemContent(materials: Node[]): Fragment[] {
|
|
149
|
+
const content: Fragment[] = [];
|
|
150
|
+
for (const material of materials) {
|
|
151
|
+
const fragments = materialFragments(material);
|
|
152
|
+
if (fragments.length === 0) continue;
|
|
153
|
+
if (fragments.every((fragment) => typeof fragment === "string")) {
|
|
154
|
+
content.push({ kind: "xml", namespace: asiNamespace, name: "p", children: fragments });
|
|
155
|
+
} else {
|
|
156
|
+
content.push(...fragments);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return content;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- presentation traversal ------------------------------------------------
|
|
163
|
+
|
|
164
|
+
type CollectedPresentation = {
|
|
165
|
+
stemMaterials: Node[];
|
|
166
|
+
response: Node | undefined;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function collectPresentation(presentation: Node): CollectedPresentation {
|
|
170
|
+
const stemMaterials: Node[] = [];
|
|
171
|
+
let response: Node | undefined;
|
|
172
|
+
|
|
173
|
+
const visit = (node: Node): void => {
|
|
174
|
+
const kind = strOf(node, "kind");
|
|
175
|
+
if (kind === "response_lid" || kind === "response_str") {
|
|
176
|
+
if (!response) response = node;
|
|
177
|
+
const leading = node["leading"];
|
|
178
|
+
if (leading && strOf(asNode(leading), "kind") === "material") stemMaterials.push(asNode(leading));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (kind === "material") {
|
|
182
|
+
stemMaterials.push(node);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (kind === "flow") {
|
|
186
|
+
for (const child of arr(node["children"])) visit(child);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const flow = presentation["flow"];
|
|
191
|
+
if (flow) {
|
|
192
|
+
visit(asNode(flow));
|
|
193
|
+
} else {
|
|
194
|
+
for (const child of arr(presentation["children"])) visit(child);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { stemMaterials, response };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function renderChoiceLabels(response: Node): Node[] {
|
|
201
|
+
const render = asNode(response["render"]);
|
|
202
|
+
return arr(render["children"]).filter((child) => strOf(child, "kind") === "response_label");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function choiceContent(label: Node): Fragment[] {
|
|
206
|
+
const materials = arr(label["children"]).filter((child) => strOf(child, "kind") === "material");
|
|
207
|
+
const fragments = materials.flatMap(materialFragments);
|
|
208
|
+
return fragments.length > 0 ? fragments : [strOf(label, "ident") ?? ""];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- response processing extraction ----------------------------------------
|
|
212
|
+
|
|
213
|
+
type VarEqual = { respident: string; value: string; caseSensitive: boolean };
|
|
214
|
+
|
|
215
|
+
function collectVarequals(item: Node): VarEqual[] {
|
|
216
|
+
const result: VarEqual[] = [];
|
|
217
|
+
const walk = (node: Node): void => {
|
|
218
|
+
const kind = strOf(node, "kind");
|
|
219
|
+
if (kind === "varequal" || kind === "varsubstring") {
|
|
220
|
+
result.push({
|
|
221
|
+
respident: strOf(node, "respident") ?? "",
|
|
222
|
+
value: strOf(node, "value") ?? "",
|
|
223
|
+
caseSensitive: strOf(node, "case") === "Yes",
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (kind === "and" || kind === "or" || kind === "not") {
|
|
228
|
+
for (const test of arr(node["tests"])) walk(test);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
for (const resprocessing of arr(item["resprocessing"])) {
|
|
232
|
+
for (const respcondition of arr(resprocessing["respcondition"])) {
|
|
233
|
+
for (const test of arr(asNode(respcondition["conditionvar"])["tests"])) walk(test);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- item conversion -------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
function findCcProfile(item: Node): string | undefined {
|
|
242
|
+
for (const metadata of arr(asNode(item["itemmetadata"])["qtimetadata"])) {
|
|
243
|
+
for (const field of arr(metadata["qtimetadatafield"])) {
|
|
244
|
+
if (strOf(field, "fieldlabel") === "cc_profile") return strOf(field, "fieldentry");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function scoreOutcomeDeclaration(): Node {
|
|
251
|
+
return {
|
|
252
|
+
identifier: "SCORE",
|
|
253
|
+
cardinality: "single",
|
|
254
|
+
baseType: "float",
|
|
255
|
+
defaultValue: { values: [{ value: "0" }] },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
type BuiltItem = { document: Node; interactionKind: CcQtiInteractionKind };
|
|
260
|
+
|
|
261
|
+
function buildChoiceItem(item: Node, response: Node, stem: Node[]): BuiltItem {
|
|
262
|
+
const rawResponseId = strOf(response, "ident") ?? "RESPONSE";
|
|
263
|
+
const responseId = sanitizeIdentifier(rawResponseId);
|
|
264
|
+
const rcardinality = strOf(response, "rcardinality") ?? "Single";
|
|
265
|
+
const cardinality = rcardinality === "Multiple" ? "multiple" : rcardinality === "Ordered" ? "ordered" : "single";
|
|
266
|
+
const labels = renderChoiceLabels(response);
|
|
267
|
+
// Choice idents are identifiers, so the correct-answer values (which reference them) get the
|
|
268
|
+
// same sanitization — keeping the reference intact after numeric idents are rewritten.
|
|
269
|
+
const correct = collectVarequals(item)
|
|
270
|
+
.filter((entry) => entry.respident === rawResponseId)
|
|
271
|
+
.map((entry) => sanitizeIdentifier(entry.value));
|
|
272
|
+
|
|
273
|
+
const interaction: Node = {
|
|
274
|
+
kind: "choiceInteraction",
|
|
275
|
+
responseIdentifier: responseId,
|
|
276
|
+
shuffle: strOf(asNode(response["render"]), "shuffle") === "Yes",
|
|
277
|
+
maxChoices: cardinality === "single" ? 1 : 0,
|
|
278
|
+
simpleChoices: labels.map((label) => ({
|
|
279
|
+
kind: "simpleChoice",
|
|
280
|
+
identifier: sanitizeIdentifier(strOf(label, "ident") ?? ""),
|
|
281
|
+
content: choiceContent(label),
|
|
282
|
+
})),
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const responseDeclaration: Node = {
|
|
286
|
+
identifier: responseId,
|
|
287
|
+
cardinality,
|
|
288
|
+
baseType: "identifier",
|
|
289
|
+
correctResponse: { values: correct.map((value) => ({ value })) },
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
interactionKind: "choice",
|
|
294
|
+
document: {
|
|
295
|
+
responseDeclarations: [responseDeclaration],
|
|
296
|
+
outcomeDeclarations: [scoreOutcomeDeclaration()],
|
|
297
|
+
itemBody: { content: [...stemContent(stem), interaction] },
|
|
298
|
+
responseProcessing: { template: MATCH_CORRECT },
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function buildTextEntryItem(item: Node, response: Node, stem: Node[]): BuiltItem {
|
|
304
|
+
const rawResponseId = strOf(response, "ident") ?? "RESPONSE";
|
|
305
|
+
const responseId = sanitizeIdentifier(rawResponseId);
|
|
306
|
+
// Text-entry correct answers are free strings (baseType string), so they are NOT sanitized.
|
|
307
|
+
const correct = collectVarequals(item).filter((entry) => entry.respident === rawResponseId);
|
|
308
|
+
|
|
309
|
+
const responseDeclaration: Node = {
|
|
310
|
+
identifier: responseId,
|
|
311
|
+
cardinality: "single",
|
|
312
|
+
baseType: "string",
|
|
313
|
+
...(correct[0] ? { correctResponse: { values: [{ value: correct[0].value }] } } : {}),
|
|
314
|
+
mapping: {
|
|
315
|
+
defaultValue: 0,
|
|
316
|
+
mapEntries: correct.map((entry) => ({
|
|
317
|
+
mapKey: entry.value,
|
|
318
|
+
mappedValue: 1,
|
|
319
|
+
caseSensitive: entry.caseSensitive,
|
|
320
|
+
})),
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const interaction: Node = { kind: "textEntryInteraction", responseIdentifier: responseId };
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
interactionKind: "textEntry",
|
|
328
|
+
document: {
|
|
329
|
+
responseDeclarations: [responseDeclaration],
|
|
330
|
+
outcomeDeclarations: [scoreOutcomeDeclaration()],
|
|
331
|
+
itemBody: { content: [...stemContent(stem), interaction] },
|
|
332
|
+
responseProcessing: { template: MAP_RESPONSE },
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildExtendedTextItem(response: Node, stem: Node[]): BuiltItem {
|
|
338
|
+
const responseId = sanitizeIdentifier(strOf(response, "ident") ?? "RESPONSE");
|
|
339
|
+
return {
|
|
340
|
+
interactionKind: "extendedText",
|
|
341
|
+
document: {
|
|
342
|
+
responseDeclarations: [{ identifier: responseId, cardinality: "single", baseType: "string" }],
|
|
343
|
+
outcomeDeclarations: [scoreOutcomeDeclaration()],
|
|
344
|
+
// Essay / constructed-response: human-scored, so no response-processing template.
|
|
345
|
+
itemBody: {
|
|
346
|
+
content: [...stemContent(stem), { kind: "extendedTextInteraction", responseIdentifier: responseId }],
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function convertItem(item: Node, identifier: string): CcQtiConvertedItem {
|
|
353
|
+
const title = strOf(item, "title") ?? identifier;
|
|
354
|
+
const ccProfile = findCcProfile(item);
|
|
355
|
+
|
|
356
|
+
const presentation = asNode(item["presentation"]);
|
|
357
|
+
const { stemMaterials, response } = collectPresentation(presentation);
|
|
358
|
+
|
|
359
|
+
let built: BuiltItem;
|
|
360
|
+
if (!response) {
|
|
361
|
+
built = buildExtendedTextItem({ ident: "RESPONSE" }, stemMaterials);
|
|
362
|
+
} else {
|
|
363
|
+
const responseKind = strOf(response, "kind");
|
|
364
|
+
const renderKind = strOf(asNode(response["render"]), "kind");
|
|
365
|
+
const hasCorrect = collectVarequals(item).length > 0;
|
|
366
|
+
|
|
367
|
+
if (responseKind === "response_lid" && renderKind === "render_choice") {
|
|
368
|
+
built = buildChoiceItem(item, response, stemMaterials);
|
|
369
|
+
} else if (responseKind === "response_str" && hasCorrect) {
|
|
370
|
+
built = buildTextEntryItem(item, response, stemMaterials);
|
|
371
|
+
} else {
|
|
372
|
+
built = buildExtendedTextItem(response, stemMaterials);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const assessmentItem: Node = {
|
|
377
|
+
identifier,
|
|
378
|
+
title,
|
|
379
|
+
timeDependent: false,
|
|
380
|
+
...built.document,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const xml = serializeQtiDocument("3.0.1", "qtiAssessmentItemDocument", { assessmentItem });
|
|
384
|
+
return { identifier, title, ccProfile, interactionKind: built.interactionKind, xml };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function buildTest(assessment: Node, items: CcQtiConvertedItem[]): CcQtiConvertedTest {
|
|
388
|
+
const identifier = strOf(assessment, "ident") ?? "assessment";
|
|
389
|
+
const title = strOf(assessment, "title") ?? identifier;
|
|
390
|
+
const section = asNode(assessment["section"]);
|
|
391
|
+
const sectionId = strOf(section, "ident") ?? "root_section";
|
|
392
|
+
|
|
393
|
+
const assessmentTest: Node = {
|
|
394
|
+
identifier,
|
|
395
|
+
title,
|
|
396
|
+
outcomeDeclarations: [{ identifier: "SCORE", cardinality: "single", baseType: "float" }],
|
|
397
|
+
testParts: [
|
|
398
|
+
{
|
|
399
|
+
identifier: "testpart-1",
|
|
400
|
+
navigationMode: "nonlinear",
|
|
401
|
+
submissionMode: "individual",
|
|
402
|
+
children: [
|
|
403
|
+
{
|
|
404
|
+
identifier: sectionId,
|
|
405
|
+
title: strOf(section, "title") ?? title,
|
|
406
|
+
visible: true,
|
|
407
|
+
children: items.map((item) => ({
|
|
408
|
+
identifier: `ref-${item.identifier}`,
|
|
409
|
+
href: `${item.identifier}.xml`,
|
|
410
|
+
})),
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
identifier,
|
|
419
|
+
title,
|
|
420
|
+
itemIdentifiers: items.map((item) => item.identifier),
|
|
421
|
+
xml: serializeQtiDocument("3.0.1", "qtiAssessmentTestDocument", { assessmentTest }),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function convertItems(rawItems: Node[]): CcQtiConvertedItem[] {
|
|
426
|
+
// Sanitize + de-duplicate item identifiers up front: real exports (Canvas/TopKit) reuse one
|
|
427
|
+
// item ident across a whole quiz, and QTI 3 requires unique, NCName-ish identifiers.
|
|
428
|
+
const identifiers = assignUniqueIdentifiers(rawItems.map((item) => strOf(item, "ident") ?? "item"));
|
|
429
|
+
return rawItems.map((item, index) => convertItem(item, identifiers[index]!));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Convert a CC `questestinterop` XML string into QTI 3.0.1 artifacts. Returns the converted
|
|
434
|
+
* item XMLs (+ a test XML when the source is an `<assessment>`), or a structured `invalid`
|
|
435
|
+
* result when the input is not even structurally valid CC QTI.
|
|
436
|
+
*
|
|
437
|
+
* Validation defaults to **structural** (the raw CC schema). The stricter CC *profile* rules
|
|
438
|
+
* (item-type coherence, no duplicate idents, feedback linkage) are opt-in via `{ profile: true }`
|
|
439
|
+
* — they are a conformance gate for clean cartridges, not a bar a best-effort import of a
|
|
440
|
+
* real-world export should trip over (those routinely violate the profile).
|
|
441
|
+
*/
|
|
442
|
+
export function convertCcQtiV1ToV3(xml: string, options?: { profile?: boolean }): CcQtiConversionResult {
|
|
443
|
+
const normalized = normalizeQuestestinterop(xml, { profile: options?.profile ?? false });
|
|
444
|
+
if (normalized.status === "invalid") {
|
|
445
|
+
return { status: "invalid", issues: normalized.issues };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const root = normalized.document.questestinterop as unknown as Node;
|
|
449
|
+
|
|
450
|
+
if ("assessment" in root) {
|
|
451
|
+
const assessment = asNode(root["assessment"]);
|
|
452
|
+
const section = asNode(assessment["section"]);
|
|
453
|
+
const items = convertItems(arr(section["item"]));
|
|
454
|
+
return { status: "converted", source: "assessment", items, test: buildTest(assessment, items) };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const objectbank = asNode(root["objectbank"]);
|
|
458
|
+
const items = convertItems(arr(objectbank["item"]));
|
|
459
|
+
return { status: "converted", source: "objectbank", items };
|
|
460
|
+
}
|