@conform-ed/qti-xml 0.0.19 → 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
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a Common Cartridge QTI ASI 1.2.1 (`questestinterop`) document — the QTI dialect
|
|
3
|
+
* CC 1.3/1.4 carries — from raw XML into the `kind`-tagged structure described by
|
|
4
|
+
* `@conform-ed/contracts/common-cartridge/v1_4`. This mirrors the QTI 3 `normalize.ts`
|
|
5
|
+
* pattern (XML node tree → typed contract shape) and is the input stage of the CC→QTI-3
|
|
6
|
+
* bridge (`convert-to-v3.ts`). The output is validated against the official CC profile, so
|
|
7
|
+
* non-conformant questestinterop is rejected here rather than silently mis-converted.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
QtiQuestestinteropProfileDocumentSchema,
|
|
12
|
+
QtiQuestestinteropRawDocumentSchema,
|
|
13
|
+
type QtiQuestestinteropRaw,
|
|
14
|
+
} from "@conform-ed/contracts/common-cartridge/v1_4";
|
|
15
|
+
|
|
16
|
+
import { parseXmlDocument, type QtiXmlElementNode, type QtiXmlNode } from "../parse-xml";
|
|
17
|
+
|
|
18
|
+
/** CC 1.x carries QTI ASI 1.2.1 under this namespace. */
|
|
19
|
+
export const ccQtiNamespace = "http://www.imsglobal.org/xsd/ims_qtiasiv1p2";
|
|
20
|
+
|
|
21
|
+
type Json = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
function elements(node: QtiXmlElementNode, localName?: string): QtiXmlElementNode[] {
|
|
24
|
+
return node.children.filter(
|
|
25
|
+
(child): child is QtiXmlElementNode =>
|
|
26
|
+
child.type === "element" && (localName === undefined || child.localName === localName),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function firstElement(node: QtiXmlElementNode, localName: string): QtiXmlElementNode | undefined {
|
|
31
|
+
return elements(node, localName)[0];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function attr(node: QtiXmlElementNode, name: string): string | undefined {
|
|
35
|
+
const value = node.attributes[name];
|
|
36
|
+
return value === undefined ? undefined : value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Concatenate the direct + descendant text of a node (mattext/varequal/setvar carry text). */
|
|
40
|
+
function textOf(node: QtiXmlElementNode): string {
|
|
41
|
+
const parts: string[] = [];
|
|
42
|
+
const walk = (children: QtiXmlNode[]): void => {
|
|
43
|
+
for (const child of children) {
|
|
44
|
+
if (child.type === "text") {
|
|
45
|
+
parts.push(child.value);
|
|
46
|
+
} else {
|
|
47
|
+
walk(child.children);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
walk(node.children);
|
|
52
|
+
return parts.join("").trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function put(target: Json, key: string, value: string | undefined): void {
|
|
56
|
+
if (value !== undefined) {
|
|
57
|
+
target[key] = value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- material / content ----------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function mapMatText(node: QtiXmlElementNode): Json {
|
|
64
|
+
const out: Json = { kind: "mattext", value: textOf(node) };
|
|
65
|
+
put(out, "texttype", attr(node, "texttype"));
|
|
66
|
+
put(out, "charset", attr(node, "charset"));
|
|
67
|
+
put(out, "label", attr(node, "label"));
|
|
68
|
+
put(out, "uri", attr(node, "uri"));
|
|
69
|
+
put(out, "width", attr(node, "width"));
|
|
70
|
+
put(out, "height", attr(node, "height"));
|
|
71
|
+
put(out, "x0", attr(node, "x0"));
|
|
72
|
+
put(out, "y0", attr(node, "y0"));
|
|
73
|
+
put(out, "xmlLang", attr(node, "xml:lang"));
|
|
74
|
+
put(out, "xmlSpace", attr(node, "xml:space"));
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mapMaterialChild(node: QtiXmlElementNode): Json | undefined {
|
|
79
|
+
switch (node.localName) {
|
|
80
|
+
case "mattext":
|
|
81
|
+
return mapMatText(node);
|
|
82
|
+
case "matref":
|
|
83
|
+
return { kind: "matref", linkrefid: attr(node, "linkrefid") ?? "" };
|
|
84
|
+
case "matbreak":
|
|
85
|
+
return { kind: "matbreak" };
|
|
86
|
+
default:
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function mapAltMaterial(node: QtiXmlElementNode): Json {
|
|
92
|
+
const out: Json = {
|
|
93
|
+
kind: "altmaterial",
|
|
94
|
+
children: elements(node)
|
|
95
|
+
.map(mapMaterialChild)
|
|
96
|
+
.filter((child): child is Json => child !== undefined),
|
|
97
|
+
};
|
|
98
|
+
put(out, "xmlLang", attr(node, "xml:lang"));
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function mapMaterial(node: QtiXmlElementNode): Json {
|
|
103
|
+
const out: Json = {
|
|
104
|
+
kind: "material",
|
|
105
|
+
children: elements(node)
|
|
106
|
+
.filter((child) => child.localName !== "altmaterial")
|
|
107
|
+
.map(mapMaterialChild)
|
|
108
|
+
.filter((child): child is Json => child !== undefined),
|
|
109
|
+
};
|
|
110
|
+
put(out, "label", attr(node, "label"));
|
|
111
|
+
put(out, "xmlLang", attr(node, "xml:lang"));
|
|
112
|
+
const altmaterial = elements(node, "altmaterial");
|
|
113
|
+
if (altmaterial.length > 0) {
|
|
114
|
+
out["altmaterial"] = altmaterial.map(mapAltMaterial);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function mapMaterialRef(node: QtiXmlElementNode): Json {
|
|
120
|
+
return { kind: "material_ref", linkrefid: attr(node, "linkrefid") ?? "" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mapFlowMat(node: QtiXmlElementNode): Json {
|
|
124
|
+
const out: Json = {
|
|
125
|
+
kind: "flow_mat",
|
|
126
|
+
children: elements(node)
|
|
127
|
+
.map((child) => {
|
|
128
|
+
if (child.localName === "flow_mat") return mapFlowMat(child);
|
|
129
|
+
if (child.localName === "material") return mapMaterial(child);
|
|
130
|
+
if (child.localName === "material_ref") return mapMaterialRef(child);
|
|
131
|
+
return undefined;
|
|
132
|
+
})
|
|
133
|
+
.filter((child): child is Json => child !== undefined),
|
|
134
|
+
};
|
|
135
|
+
put(out, "class", attr(node, "class"));
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- presentation / responses ----------------------------------------------
|
|
140
|
+
|
|
141
|
+
function mapResponseLabel(node: QtiXmlElementNode): Json {
|
|
142
|
+
const out: Json = { kind: "response_label", ident: attr(node, "ident") ?? "" };
|
|
143
|
+
put(out, "labelrefid", attr(node, "labelrefid"));
|
|
144
|
+
put(out, "rshuffle", attr(node, "rshuffle"));
|
|
145
|
+
put(out, "match_group", attr(node, "match_group"));
|
|
146
|
+
put(out, "match_max", attr(node, "match_max"));
|
|
147
|
+
const children = elements(node)
|
|
148
|
+
.map((child) => {
|
|
149
|
+
if (child.localName === "material") return mapMaterial(child);
|
|
150
|
+
if (child.localName === "material_ref") return mapMaterialRef(child);
|
|
151
|
+
if (child.localName === "flow_mat") return mapFlowMat(child);
|
|
152
|
+
return undefined;
|
|
153
|
+
})
|
|
154
|
+
.filter((child): child is Json => child !== undefined);
|
|
155
|
+
if (children.length > 0) {
|
|
156
|
+
out["children"] = children;
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function mapFlowLabel(node: QtiXmlElementNode): Json {
|
|
162
|
+
const out: Json = {
|
|
163
|
+
kind: "flow_label",
|
|
164
|
+
children: elements(node)
|
|
165
|
+
.map((child) => {
|
|
166
|
+
if (child.localName === "flow_label") return mapFlowLabel(child);
|
|
167
|
+
if (child.localName === "response_label") return mapResponseLabel(child);
|
|
168
|
+
return undefined;
|
|
169
|
+
})
|
|
170
|
+
.filter((child): child is Json => child !== undefined),
|
|
171
|
+
};
|
|
172
|
+
put(out, "class", attr(node, "class"));
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function mapRenderChildren(node: QtiXmlElementNode): Json[] {
|
|
177
|
+
return elements(node)
|
|
178
|
+
.map((child) => {
|
|
179
|
+
if (child.localName === "material") return mapMaterial(child);
|
|
180
|
+
if (child.localName === "material_ref") return mapMaterialRef(child);
|
|
181
|
+
if (child.localName === "response_label") return mapResponseLabel(child);
|
|
182
|
+
if (child.localName === "flow_label") return mapFlowLabel(child);
|
|
183
|
+
return undefined;
|
|
184
|
+
})
|
|
185
|
+
.filter((child): child is Json => child !== undefined);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function mapRender(node: QtiXmlElementNode): Json {
|
|
189
|
+
if (node.localName === "render_fib") {
|
|
190
|
+
const out: Json = { kind: "render_fib" };
|
|
191
|
+
put(out, "encoding", attr(node, "encoding"));
|
|
192
|
+
put(out, "charset", attr(node, "charset"));
|
|
193
|
+
put(out, "rows", attr(node, "rows"));
|
|
194
|
+
put(out, "columns", attr(node, "columns"));
|
|
195
|
+
put(out, "maxchars", attr(node, "maxchars"));
|
|
196
|
+
put(out, "minnumber", attr(node, "minnumber"));
|
|
197
|
+
put(out, "maxnumber", attr(node, "maxnumber"));
|
|
198
|
+
put(out, "prompt", attr(node, "prompt"));
|
|
199
|
+
put(out, "fibtype", attr(node, "fibtype"));
|
|
200
|
+
const children = mapRenderChildren(node);
|
|
201
|
+
if (children.length > 0) out["children"] = children;
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
// render_choice (default)
|
|
205
|
+
const out: Json = { kind: "render_choice" };
|
|
206
|
+
put(out, "shuffle", attr(node, "shuffle"));
|
|
207
|
+
put(out, "minnumber", attr(node, "minnumber"));
|
|
208
|
+
put(out, "maxnumber", attr(node, "maxnumber"));
|
|
209
|
+
const children = mapRenderChildren(node);
|
|
210
|
+
if (children.length > 0) out["children"] = children;
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function mapLeadingTrailing(node: QtiXmlElementNode): Json | undefined {
|
|
215
|
+
if (node.localName === "material") return mapMaterial(node);
|
|
216
|
+
if (node.localName === "material_ref") return mapMaterialRef(node);
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function mapResponse(node: QtiXmlElementNode): Json {
|
|
221
|
+
const kind = node.localName === "response_str" ? "response_str" : "response_lid";
|
|
222
|
+
const out: Json = { kind, ident: attr(node, "ident") ?? "" };
|
|
223
|
+
put(out, "rcardinality", attr(node, "rcardinality"));
|
|
224
|
+
put(out, "rtiming", attr(node, "rtiming"));
|
|
225
|
+
|
|
226
|
+
const renderNode = elements(node).find((child) => child.localName.startsWith("render_"));
|
|
227
|
+
// `render` is required by the contract; if absent we still emit an empty render_choice so
|
|
228
|
+
// the structural shape is preserved and validation surfaces the omission.
|
|
229
|
+
out["render"] = renderNode ? mapRender(renderNode) : { kind: "render_choice" };
|
|
230
|
+
|
|
231
|
+
const childEls = elements(node);
|
|
232
|
+
const leadingIndex = childEls.findIndex((c) => c.localName === "material" || c.localName === "material_ref");
|
|
233
|
+
const renderIndex = childEls.findIndex((c) => c.localName.startsWith("render_"));
|
|
234
|
+
if (leadingIndex !== -1 && (renderIndex === -1 || leadingIndex < renderIndex)) {
|
|
235
|
+
const leading = mapLeadingTrailing(childEls[leadingIndex]!);
|
|
236
|
+
if (leading) out["leading"] = leading;
|
|
237
|
+
}
|
|
238
|
+
const trailingEls = childEls.filter(
|
|
239
|
+
(c, i) => (c.localName === "material" || c.localName === "material_ref") && i > renderIndex && renderIndex !== -1,
|
|
240
|
+
);
|
|
241
|
+
if (trailingEls.length > 0) {
|
|
242
|
+
const trailing = mapLeadingTrailing(trailingEls[0]!);
|
|
243
|
+
if (trailing) out["trailing"] = trailing;
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function mapFlow(node: QtiXmlElementNode): Json {
|
|
249
|
+
const out: Json = {
|
|
250
|
+
kind: "flow",
|
|
251
|
+
children: elements(node)
|
|
252
|
+
.map((child) => {
|
|
253
|
+
if (child.localName === "flow") return mapFlow(child);
|
|
254
|
+
if (child.localName === "material") return mapMaterial(child);
|
|
255
|
+
if (child.localName === "material_ref") return mapMaterialRef(child);
|
|
256
|
+
if (child.localName === "response_lid" || child.localName === "response_str") return mapResponse(child);
|
|
257
|
+
return undefined;
|
|
258
|
+
})
|
|
259
|
+
.filter((child): child is Json => child !== undefined),
|
|
260
|
+
};
|
|
261
|
+
put(out, "class", attr(node, "class"));
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function mapPresentation(node: QtiXmlElementNode): Json {
|
|
266
|
+
const flowNode = firstElement(node, "flow");
|
|
267
|
+
const common: Json = {};
|
|
268
|
+
put(common, "label", attr(node, "label"));
|
|
269
|
+
put(common, "xmlLang", attr(node, "xml:lang"));
|
|
270
|
+
put(common, "x0", attr(node, "x0"));
|
|
271
|
+
put(common, "y0", attr(node, "y0"));
|
|
272
|
+
put(common, "width", attr(node, "width"));
|
|
273
|
+
put(common, "height", attr(node, "height"));
|
|
274
|
+
|
|
275
|
+
if (flowNode) {
|
|
276
|
+
return { flow: mapFlow(flowNode), ...common };
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
children: elements(node)
|
|
280
|
+
.map((child) => {
|
|
281
|
+
if (child.localName === "material") return mapMaterial(child);
|
|
282
|
+
if (child.localName === "response_lid" || child.localName === "response_str") return mapResponse(child);
|
|
283
|
+
return undefined;
|
|
284
|
+
})
|
|
285
|
+
.filter((child): child is Json => child !== undefined),
|
|
286
|
+
...common,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- response processing ---------------------------------------------------
|
|
291
|
+
|
|
292
|
+
function mapConditionNode(node: QtiXmlElementNode): Json | undefined {
|
|
293
|
+
switch (node.localName) {
|
|
294
|
+
case "varequal":
|
|
295
|
+
return {
|
|
296
|
+
kind: "varequal",
|
|
297
|
+
value: textOf(node),
|
|
298
|
+
respident: attr(node, "respident") ?? "",
|
|
299
|
+
...(attr(node, "case") !== undefined ? { case: attr(node, "case") } : {}),
|
|
300
|
+
};
|
|
301
|
+
case "varsubstring":
|
|
302
|
+
return {
|
|
303
|
+
kind: "varsubstring",
|
|
304
|
+
value: textOf(node),
|
|
305
|
+
respident: attr(node, "respident") ?? "",
|
|
306
|
+
...(attr(node, "case") !== undefined ? { case: attr(node, "case") } : {}),
|
|
307
|
+
};
|
|
308
|
+
case "and":
|
|
309
|
+
return { kind: "and", tests: mapConditionTests(node) };
|
|
310
|
+
case "or":
|
|
311
|
+
return { kind: "or", tests: mapConditionTests(node) };
|
|
312
|
+
case "not":
|
|
313
|
+
return { kind: "not", tests: mapConditionTests(node) };
|
|
314
|
+
case "other":
|
|
315
|
+
return { kind: "other" };
|
|
316
|
+
default:
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function mapConditionTests(node: QtiXmlElementNode): Json[] {
|
|
322
|
+
return elements(node)
|
|
323
|
+
.map(mapConditionNode)
|
|
324
|
+
.filter((child): child is Json => child !== undefined);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function mapRespCondition(node: QtiXmlElementNode): Json {
|
|
328
|
+
const conditionvar = firstElement(node, "conditionvar");
|
|
329
|
+
const out: Json = {
|
|
330
|
+
conditionvar: { tests: conditionvar ? mapConditionTests(conditionvar) : [] },
|
|
331
|
+
};
|
|
332
|
+
put(out, "title", attr(node, "title"));
|
|
333
|
+
put(out, "continue", attr(node, "continue"));
|
|
334
|
+
|
|
335
|
+
const setvars = elements(node, "setvar").map((sv) => {
|
|
336
|
+
const entry: Json = { value: textOf(sv) };
|
|
337
|
+
put(entry, "varname", attr(sv, "varname"));
|
|
338
|
+
put(entry, "action", attr(sv, "action"));
|
|
339
|
+
return entry;
|
|
340
|
+
});
|
|
341
|
+
if (setvars.length > 0) out["setvar"] = setvars;
|
|
342
|
+
|
|
343
|
+
const feedbacks = elements(node, "displayfeedback").map((df) => {
|
|
344
|
+
const entry: Json = {
|
|
345
|
+
feedbacktype: attr(df, "feedbacktype") ?? "Response",
|
|
346
|
+
linkrefid: attr(df, "linkrefid") ?? "",
|
|
347
|
+
};
|
|
348
|
+
const value = textOf(df);
|
|
349
|
+
if (value.length > 0) entry["value"] = value;
|
|
350
|
+
return entry;
|
|
351
|
+
});
|
|
352
|
+
if (feedbacks.length > 0) out["displayfeedback"] = feedbacks;
|
|
353
|
+
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function mapResprocessing(node: QtiXmlElementNode): Json {
|
|
358
|
+
const outcomesNode = firstElement(node, "outcomes");
|
|
359
|
+
const decvarNode = outcomesNode ? firstElement(outcomesNode, "decvar") : undefined;
|
|
360
|
+
const decvar: Json = {
|
|
361
|
+
value: attr(decvarNode ?? node, "defaultval") ?? "",
|
|
362
|
+
varname: attr(decvarNode ?? node, "varname") ?? "SCORE",
|
|
363
|
+
};
|
|
364
|
+
put(decvar, "vartype", attr(decvarNode ?? node, "vartype"));
|
|
365
|
+
put(decvar, "minvalue", attr(decvarNode ?? node, "minvalue"));
|
|
366
|
+
put(decvar, "maxvalue", attr(decvarNode ?? node, "maxvalue"));
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
outcomes: { decvar },
|
|
370
|
+
respcondition: elements(node, "respcondition").map(mapRespCondition),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --- item feedback ---------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
function mapFeedbackMaterialList(node: QtiXmlElementNode): Json {
|
|
377
|
+
const flowMats = elements(node, "flow_mat");
|
|
378
|
+
if (flowMats.length > 0) {
|
|
379
|
+
return { flow_mat: flowMats.map(mapFlowMat) };
|
|
380
|
+
}
|
|
381
|
+
return { material: elements(node, "material").map(mapMaterial) };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function mapSolution(node: QtiXmlElementNode): Json {
|
|
385
|
+
const out: Json = {
|
|
386
|
+
kind: "solution",
|
|
387
|
+
solutionmaterial: elements(node, "solutionmaterial").map(mapFeedbackMaterialList),
|
|
388
|
+
};
|
|
389
|
+
put(out, "feedbackstyle", attr(node, "feedbackstyle"));
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function mapHint(node: QtiXmlElementNode): Json {
|
|
394
|
+
const out: Json = { kind: "hint", hintmaterial: elements(node, "hintmaterial").map(mapFeedbackMaterialList) };
|
|
395
|
+
put(out, "feedbackstyle", attr(node, "feedbackstyle"));
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function mapItemFeedback(node: QtiXmlElementNode): Json {
|
|
400
|
+
const out: Json = {
|
|
401
|
+
ident: attr(node, "ident") ?? "",
|
|
402
|
+
children: elements(node)
|
|
403
|
+
.map((child) => {
|
|
404
|
+
if (child.localName === "flow_mat") return mapFlowMat(child);
|
|
405
|
+
if (child.localName === "material") return mapMaterial(child);
|
|
406
|
+
if (child.localName === "solution") return mapSolution(child);
|
|
407
|
+
if (child.localName === "hint") return mapHint(child);
|
|
408
|
+
return undefined;
|
|
409
|
+
})
|
|
410
|
+
.filter((child): child is Json => child !== undefined),
|
|
411
|
+
};
|
|
412
|
+
put(out, "title", attr(node, "title"));
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// --- item / metadata / section / assessment / objectbank -------------------
|
|
417
|
+
|
|
418
|
+
function mapQtimetadata(node: QtiXmlElementNode): Json {
|
|
419
|
+
return {
|
|
420
|
+
qtimetadatafield: elements(node, "qtimetadatafield").map((field) => {
|
|
421
|
+
const labelNode = firstElement(field, "fieldlabel");
|
|
422
|
+
const entryNode = firstElement(field, "fieldentry");
|
|
423
|
+
const out: Json = {
|
|
424
|
+
fieldlabel: labelNode ? textOf(labelNode) : "",
|
|
425
|
+
fieldentry: entryNode ? textOf(entryNode) : "",
|
|
426
|
+
};
|
|
427
|
+
put(out, "xmlLang", attr(field, "xml:lang"));
|
|
428
|
+
return out;
|
|
429
|
+
}),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function mapItem(node: QtiXmlElementNode): Json {
|
|
434
|
+
const out: Json = { ident: attr(node, "ident") ?? "" };
|
|
435
|
+
put(out, "title", attr(node, "title"));
|
|
436
|
+
put(out, "xmlLang", attr(node, "xml:lang"));
|
|
437
|
+
|
|
438
|
+
const itemmetadata = firstElement(node, "itemmetadata");
|
|
439
|
+
if (itemmetadata) {
|
|
440
|
+
out["itemmetadata"] = {
|
|
441
|
+
qtimetadata: elements(itemmetadata, "qtimetadata").map(mapQtimetadata),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const presentation = firstElement(node, "presentation");
|
|
446
|
+
if (presentation) {
|
|
447
|
+
out["presentation"] = mapPresentation(presentation);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const resprocessing = elements(node, "resprocessing");
|
|
451
|
+
if (resprocessing.length > 0) {
|
|
452
|
+
out["resprocessing"] = resprocessing.map(mapResprocessing);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const itemfeedback = elements(node, "itemfeedback");
|
|
456
|
+
if (itemfeedback.length > 0) {
|
|
457
|
+
out["itemfeedback"] = itemfeedback.map(mapItemFeedback);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function mapSection(node: QtiXmlElementNode): Json {
|
|
464
|
+
const out: Json = {
|
|
465
|
+
ident: attr(node, "ident") ?? "",
|
|
466
|
+
item: elements(node, "item").map(mapItem),
|
|
467
|
+
};
|
|
468
|
+
put(out, "title", attr(node, "title"));
|
|
469
|
+
put(out, "xmlLang", attr(node, "xml:lang"));
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function mapAssessment(node: QtiXmlElementNode): Json {
|
|
474
|
+
const out: Json = {
|
|
475
|
+
ident: attr(node, "ident") ?? "",
|
|
476
|
+
title: attr(node, "title") ?? "",
|
|
477
|
+
};
|
|
478
|
+
put(out, "xmlLang", attr(node, "xml:lang"));
|
|
479
|
+
const qtimetadata = firstElement(node, "qtimetadata");
|
|
480
|
+
if (qtimetadata) out["qtimetadata"] = mapQtimetadata(qtimetadata);
|
|
481
|
+
const section = firstElement(node, "section");
|
|
482
|
+
out["section"] = section ? mapSection(section) : { ident: "root_section", item: [] };
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function mapObjectbank(node: QtiXmlElementNode): Json {
|
|
487
|
+
const out: Json = {
|
|
488
|
+
ident: attr(node, "ident") ?? "",
|
|
489
|
+
item: elements(node, "item").map(mapItem),
|
|
490
|
+
};
|
|
491
|
+
const qtimetadata = firstElement(node, "qtimetadata");
|
|
492
|
+
if (qtimetadata) out["qtimetadata"] = mapQtimetadata(qtimetadata);
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export type NormalizeQuestestinteropResult =
|
|
497
|
+
| { status: "valid"; document: { questestinterop: QtiQuestestinteropRaw } }
|
|
498
|
+
| { status: "invalid"; issues: string[] };
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Parse + normalize a `questestinterop` XML string into the CC 1.4 contract shape and validate
|
|
502
|
+
* it. When `profile` is true (default), the stricter CC profile rules are applied (item-type
|
|
503
|
+
* coherence, feedback linkage); when false, only the structural raw schema is checked.
|
|
504
|
+
*/
|
|
505
|
+
export function normalizeQuestestinterop(xml: string, options?: { profile?: boolean }): NormalizeQuestestinteropResult {
|
|
506
|
+
const profile = options?.profile ?? true;
|
|
507
|
+
|
|
508
|
+
let root: QtiXmlElementNode;
|
|
509
|
+
try {
|
|
510
|
+
root = parseXmlDocument(xml);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
return { status: "invalid", issues: [error instanceof Error ? error.message : "Invalid XML."] };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (root.localName !== "questestinterop") {
|
|
516
|
+
return { status: "invalid", issues: [`Expected <questestinterop> root, found <${root.localName}>.`] };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const assessment = firstElement(root, "assessment");
|
|
520
|
+
const objectbank = firstElement(root, "objectbank");
|
|
521
|
+
|
|
522
|
+
let candidate: Json;
|
|
523
|
+
if (assessment) {
|
|
524
|
+
candidate = { questestinterop: { assessment: mapAssessment(assessment) } };
|
|
525
|
+
} else if (objectbank) {
|
|
526
|
+
candidate = { questestinterop: { objectbank: mapObjectbank(objectbank) } };
|
|
527
|
+
} else {
|
|
528
|
+
return { status: "invalid", issues: ["<questestinterop> must contain an <assessment> or <objectbank>."] };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const schema = profile ? QtiQuestestinteropProfileDocumentSchema : QtiQuestestinteropRawDocumentSchema;
|
|
532
|
+
const parsed = schema.safeParse(candidate);
|
|
533
|
+
if (!parsed.success) {
|
|
534
|
+
return {
|
|
535
|
+
status: "invalid",
|
|
536
|
+
issues: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
status: "valid",
|
|
542
|
+
document: parsed.data as { questestinterop: QtiQuestestinteropRaw },
|
|
543
|
+
};
|
|
544
|
+
}
|
package/src/index.ts
CHANGED