@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.
Files changed (68) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +13 -0
  3. package/dist/diagnostics.d.ts +7 -0
  4. package/dist/diagnostics.d.ts.map +1 -0
  5. package/dist/diagnostics.js +7 -0
  6. package/dist/diagnostics.js.map +1 -0
  7. package/dist/index.d.ts +7 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +262 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/options.d.ts +3 -0
  12. package/dist/options.d.ts.map +1 -0
  13. package/dist/options.js +7 -0
  14. package/dist/options.js.map +1 -0
  15. package/dist/qti12-item.d.ts +7 -0
  16. package/dist/qti12-item.d.ts.map +1 -0
  17. package/dist/qti12-item.js +393 -0
  18. package/dist/qti12-item.js.map +1 -0
  19. package/dist/qti2-body.d.ts +11 -0
  20. package/dist/qti2-body.d.ts.map +1 -0
  21. package/dist/qti2-body.js +90 -0
  22. package/dist/qti2-body.js.map +1 -0
  23. package/dist/qti2-choices.d.ts +7 -0
  24. package/dist/qti2-choices.d.ts.map +1 -0
  25. package/dist/qti2-choices.js +74 -0
  26. package/dist/qti2-choices.js.map +1 -0
  27. package/dist/qti2-graphic.d.ts +10 -0
  28. package/dist/qti2-graphic.d.ts.map +1 -0
  29. package/dist/qti2-graphic.js +30 -0
  30. package/dist/qti2-graphic.js.map +1 -0
  31. package/dist/qti2-item.d.ts +8 -0
  32. package/dist/qti2-item.d.ts.map +1 -0
  33. package/dist/qti2-item.js +494 -0
  34. package/dist/qti2-item.js.map +1 -0
  35. package/dist/qti2-response.d.ts +9 -0
  36. package/dist/qti2-response.d.ts.map +1 -0
  37. package/dist/qti2-response.js +32 -0
  38. package/dist/qti2-response.js.map +1 -0
  39. package/dist/source.d.ts +30 -0
  40. package/dist/source.d.ts.map +1 -0
  41. package/dist/source.js +197 -0
  42. package/dist/source.js.map +1 -0
  43. package/dist/text.d.ts +5 -0
  44. package/dist/text.d.ts.map +1 -0
  45. package/dist/text.js +19 -0
  46. package/dist/text.js.map +1 -0
  47. package/dist/types.d.ts +61 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +2 -0
  50. package/dist/types.js.map +1 -0
  51. package/dist/xml.d.ts +16 -0
  52. package/dist/xml.d.ts.map +1 -0
  53. package/dist/xml.js +101 -0
  54. package/dist/xml.js.map +1 -0
  55. package/package.json +56 -0
  56. package/src/diagnostics.ts +21 -0
  57. package/src/index.ts +393 -0
  58. package/src/options.ts +8 -0
  59. package/src/qti12-item.ts +507 -0
  60. package/src/qti2-body.ts +138 -0
  61. package/src/qti2-choices.ts +96 -0
  62. package/src/qti2-graphic.ts +32 -0
  63. package/src/qti2-item.ts +650 -0
  64. package/src/qti2-response.ts +41 -0
  65. package/src/source.ts +251 -0
  66. package/src/text.ts +21 -0
  67. package/src/types.ts +73 -0
  68. package/src/xml.ts +119 -0
@@ -0,0 +1,650 @@
1
+ import {
2
+ type Qti3AuthoringItem,
3
+ type Qti3GapMatchChoice,
4
+ type Qti3GraphicGapChoice,
5
+ type Qti3GraphicGapTarget,
6
+ type Qti3HottextChoice,
7
+ type Qti3InlineChoiceSlot,
8
+ type Qti3TextEntryResponse,
9
+ } from "@longsightgroup/qti3-writer";
10
+ import {
11
+ bodyWithGapPlaceholders,
12
+ bodyWithHottextPlaceholders,
13
+ bodyWithInlineChoicePlaceholders,
14
+ bodyWithoutInteraction,
15
+ bodyWithTextEntryPlaceholders,
16
+ collectInteractionElements,
17
+ prompt,
18
+ trusted,
19
+ } from "./qti2-body.js";
20
+ import { associableChoices, gapChoice, graphicGapChoice, simpleChoices } from "./qti2-choices.js";
21
+ import { graphicObject, hotspotShape } from "./qti2-graphic.js";
22
+ import {
23
+ hasMapping,
24
+ orderedIdentifierValues,
25
+ pairValues,
26
+ responseValues,
27
+ } from "./qti2-response.js";
28
+ import { diagnostic } from "./diagnostics.js";
29
+ import { normalizeIdentifier, stripTags } from "./text.js";
30
+ import type {
31
+ QtiMigrationDiagnostic,
32
+ QtiMigrationSourceFormat,
33
+ ResolvedQtiMigrationOptions,
34
+ } from "./types.js";
35
+ import {
36
+ attr,
37
+ findAllDescendantsByAnyLocalName,
38
+ findAllDescendantsByLocalName,
39
+ findDescendantByLocalName,
40
+ localName,
41
+ parseXml,
42
+ serializeChildren,
43
+ textOf,
44
+ toNumber,
45
+ type XmlElement,
46
+ } from "./xml.js";
47
+
48
+ export function migrateQti2ItemXml(
49
+ xml: string,
50
+ path: string,
51
+ sourceFormat: QtiMigrationSourceFormat,
52
+ options: ResolvedQtiMigrationOptions,
53
+ ): {
54
+ authoringItem?: Qti3AuthoringItem | undefined;
55
+ diagnostics: readonly QtiMigrationDiagnostic[];
56
+ } {
57
+ const diagnostics: QtiMigrationDiagnostic[] = [];
58
+ const doc = parseXml(xml, path);
59
+ const root = doc.documentElement;
60
+ if (localName(root) !== "assessmentitem") {
61
+ return {
62
+ diagnostics: [
63
+ diagnostic("qti2_item_root", "error", "Expected QTI 2.x assessmentItem root.", {
64
+ path,
65
+ sourceFormat,
66
+ }),
67
+ ],
68
+ };
69
+ }
70
+ const body = findDescendantByLocalName(root, "itembody");
71
+ if (!body) {
72
+ return {
73
+ diagnostics: [
74
+ diagnostic("qti2_item_body_missing", "error", "QTI 2.x item is missing itemBody.", {
75
+ path,
76
+ sourceFormat,
77
+ }),
78
+ ],
79
+ };
80
+ }
81
+ const responseDecls = findAllDescendantsByLocalName(root, "responsedeclaration");
82
+ const responseDeclMap = new Map<string, XmlElement>();
83
+ for (const declaration of responseDecls) {
84
+ const identifier = attr(declaration, "identifier");
85
+ if (identifier) responseDeclMap.set(identifier, declaration);
86
+ }
87
+ const context: Qti2Context = {
88
+ identifier: normalizeIdentifier(attr(root, "identifier"), "ITEM"),
89
+ title: attr(root, "title")?.trim() || "Imported Item",
90
+ body,
91
+ responseDecls,
92
+ responseDeclMap,
93
+ sourceFormat,
94
+ path,
95
+ options,
96
+ diagnostics,
97
+ };
98
+ const interactionCheck = supportedInteractionForItem(body, sourceFormat, path);
99
+ if (interactionCheck.diagnostics.length) {
100
+ return { diagnostics: interactionCheck.diagnostics };
101
+ }
102
+ const interaction = interactionCheck.interaction;
103
+ if (!interaction) {
104
+ return {
105
+ diagnostics: [
106
+ diagnostic(
107
+ "qti2_interaction_unsupported",
108
+ "error",
109
+ "No supported QTI 2.x interaction found.",
110
+ {
111
+ path,
112
+ sourceFormat,
113
+ },
114
+ ),
115
+ ],
116
+ };
117
+ }
118
+ const mapper = qti2Mappers[localName(interaction)];
119
+ if (!mapper) {
120
+ return {
121
+ diagnostics: [
122
+ diagnostic(
123
+ "qti2_interaction_unsupported",
124
+ "error",
125
+ `Unsupported QTI 2.x interaction ${localName(interaction)}.`,
126
+ { path, sourceFormat },
127
+ ),
128
+ ],
129
+ };
130
+ }
131
+ try {
132
+ return { authoringItem: mapper(interaction, context), diagnostics };
133
+ } catch (error) {
134
+ if (error instanceof Qti2MigrationBlocked) {
135
+ return { diagnostics: error.diagnostics };
136
+ }
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ interface Qti2Context {
142
+ readonly identifier: string;
143
+ readonly title: string;
144
+ readonly body: XmlElement;
145
+ readonly responseDecls: readonly XmlElement[];
146
+ readonly responseDeclMap: ReadonlyMap<string, XmlElement>;
147
+ readonly sourceFormat: QtiMigrationSourceFormat;
148
+ readonly path: string;
149
+ readonly options: ResolvedQtiMigrationOptions;
150
+ readonly diagnostics: QtiMigrationDiagnostic[];
151
+ }
152
+
153
+ type Qti2Mapper = (interaction: XmlElement, context: Qti2Context) => Qti3AuthoringItem;
154
+
155
+ const qti2Mappers: Record<string, Qti2Mapper | undefined> = {
156
+ choiceinteraction: mapChoice,
157
+ orderinteraction: mapOrder,
158
+ matchinteraction: mapMatch,
159
+ associateinteraction: mapAssociate,
160
+ textentryinteraction: mapTextEntry,
161
+ extendedtextinteraction: mapExtendedText,
162
+ inlinechoiceinteraction: mapInlineChoice,
163
+ hottextinteraction: mapHottext,
164
+ gapmatchinteraction: mapGapMatch,
165
+ hotspotinteraction: mapHotspot,
166
+ graphicorderinteraction: mapGraphicOrder,
167
+ graphicassociateinteraction: mapGraphicAssociate,
168
+ graphicgapmatchinteraction: mapGraphicGapMatch,
169
+ };
170
+
171
+ const supportedInteractionNames = new Set(Object.keys(qti2Mappers));
172
+
173
+ function supportedInteractionForItem(
174
+ root: XmlElement,
175
+ sourceFormat: QtiMigrationSourceFormat,
176
+ path: string,
177
+ ): {
178
+ readonly interaction?: XmlElement | undefined;
179
+ readonly diagnostics: readonly QtiMigrationDiagnostic[];
180
+ } {
181
+ const interactions = collectInteractionElements(root);
182
+ const unsupported = interactions.filter(
183
+ (interaction) => !supportedInteractionNames.has(localName(interaction)),
184
+ );
185
+ if (unsupported.length) {
186
+ return {
187
+ diagnostics: [
188
+ diagnostic(
189
+ "qti2_interaction_unsupported",
190
+ "error",
191
+ `Unsupported QTI 2.x interaction ${localName(unsupported[0])}.`,
192
+ { path, sourceFormat },
193
+ ),
194
+ ],
195
+ };
196
+ }
197
+ const supported = interactions.filter((interaction) =>
198
+ supportedInteractionNames.has(localName(interaction)),
199
+ );
200
+ const supportedNames = new Set(supported.map((interaction) => localName(interaction)));
201
+ const supportsMultipleSlots =
202
+ supportedNames.size === 1 &&
203
+ (supportedNames.has("inlinechoiceinteraction") || supportedNames.has("textentryinteraction"));
204
+ if (supported.length > 1 && !supportsMultipleSlots) {
205
+ return {
206
+ diagnostics: [
207
+ diagnostic(
208
+ "qti2_composite_interactions_unsupported",
209
+ "error",
210
+ "QTI 2.x item contains multiple interactions; partial migration is not allowed.",
211
+ { path, sourceFormat },
212
+ ),
213
+ ],
214
+ };
215
+ }
216
+ return { interaction: supported[0], diagnostics: [] };
217
+ }
218
+
219
+ function mapChoice(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
220
+ const responseIdentifier = responseIdentifierFor(interaction);
221
+ const declaration = context.responseDeclMap.get(responseIdentifier);
222
+ const cardinality = (attr(declaration, "cardinality") ?? "").toLowerCase();
223
+ const maxChoices = toNumber(attr(interaction, "maxChoices"));
224
+ const choices = simpleChoices(interaction);
225
+ const correctValues = orderedIdentifierValues(declaration);
226
+ if (cardinality === "ordered") {
227
+ return {
228
+ interactionType: "order",
229
+ identifier: context.identifier,
230
+ title: context.title,
231
+ bodyHtml: bodyWithoutInteraction(context.body, interaction),
232
+ responseIdentifier,
233
+ choices,
234
+ correctOrder: correctValues.length
235
+ ? correctValues
236
+ : choices.map((choice) => choice.identifier),
237
+ shuffle: attr(interaction, "shuffle") === "true",
238
+ minChoices: toNumber(attr(interaction, "minChoices")),
239
+ maxChoices,
240
+ };
241
+ }
242
+ const choiceCardinality =
243
+ cardinality === "multiple" || (maxChoices ?? 0) > 1 ? "multiple" : "single";
244
+ const correctResponse = correctValues.filter((value) =>
245
+ choices.some((choice) => choice.identifier === value),
246
+ );
247
+ repairOrError({
248
+ needed: !correctResponse.length,
249
+ context,
250
+ code: "qti2_choice_correct_response_missing",
251
+ message: "QTI 2.x choice interaction has no valid correct response.",
252
+ repairMessage:
253
+ "QTI 2.x choice response was missing or invalid; using the first declared choice.",
254
+ });
255
+ return {
256
+ interactionType: "choice",
257
+ identifier: context.identifier,
258
+ title: context.title,
259
+ bodyHtml: bodyWithoutInteraction(context.body, interaction),
260
+ responseIdentifier,
261
+ responseCardinality: choiceCardinality,
262
+ choices,
263
+ correctResponse: correctResponse.length
264
+ ? correctResponse
265
+ : choices.slice(0, 1).map((choice) => choice.identifier),
266
+ shuffle: attr(interaction, "shuffle") === "true",
267
+ minChoices: toNumber(attr(interaction, "minChoices")),
268
+ maxChoices: choiceCardinality === "single" ? 1 : maxChoices,
269
+ scoring: hasMapping(declaration) ? "map_response" : "match_correct",
270
+ };
271
+ }
272
+
273
+ function mapOrder(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
274
+ const responseIdentifier = responseIdentifierFor(interaction);
275
+ const choices = simpleChoices(interaction);
276
+ const correctOrder = orderedIdentifierValues(context.responseDeclMap.get(responseIdentifier));
277
+ return {
278
+ interactionType: "order",
279
+ identifier: context.identifier,
280
+ title: context.title,
281
+ bodyHtml: bodyWithoutInteraction(context.body, interaction),
282
+ responseIdentifier,
283
+ choices,
284
+ correctOrder: correctOrder.length ? correctOrder : choices.map((choice) => choice.identifier),
285
+ shuffle: attr(interaction, "shuffle") === "true",
286
+ minChoices: toNumber(attr(interaction, "minChoices")),
287
+ maxChoices: toNumber(attr(interaction, "maxChoices")),
288
+ };
289
+ }
290
+
291
+ function mapMatch(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
292
+ const responseIdentifier = responseIdentifierFor(interaction);
293
+ const sets = findAllDescendantsByLocalName(interaction, "simplematchset");
294
+ return {
295
+ interactionType: "match",
296
+ identifier: context.identifier,
297
+ title: context.title,
298
+ bodyHtml: bodyWithoutInteraction(context.body, interaction),
299
+ responseIdentifier,
300
+ sources: associableChoices(sets[0], "SOURCE"),
301
+ targets: associableChoices(sets[1], "TARGET"),
302
+ correctResponse: pairValues(context.responseDeclMap.get(responseIdentifier)),
303
+ shuffle: attr(interaction, "shuffle") === "true",
304
+ minAssociations: toNumber(attr(interaction, "minAssociations")),
305
+ maxAssociations: toNumber(attr(interaction, "maxAssociations")),
306
+ };
307
+ }
308
+
309
+ function mapAssociate(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
310
+ const responseIdentifier = responseIdentifierFor(interaction);
311
+ return {
312
+ interactionType: "associate",
313
+ identifier: context.identifier,
314
+ title: context.title,
315
+ bodyHtml: bodyWithoutInteraction(context.body, interaction),
316
+ responseIdentifier,
317
+ choices: associableChoices(interaction, "CHOICE"),
318
+ correctResponse: pairValues(context.responseDeclMap.get(responseIdentifier)),
319
+ shuffle: attr(interaction, "shuffle") === "true",
320
+ minAssociations: toNumber(attr(interaction, "minAssociations")),
321
+ maxAssociations: toNumber(attr(interaction, "maxAssociations")),
322
+ scoring: hasMapping(context.responseDeclMap.get(responseIdentifier))
323
+ ? "map_response"
324
+ : "match_correct",
325
+ };
326
+ }
327
+
328
+ function mapTextEntry(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
329
+ const interactions = findAllDescendantsByLocalName(context.body, "textentryinteraction");
330
+ const responses = interactions.map((entry, index): Qti3TextEntryResponse => {
331
+ const responseIdentifier = responseIdentifierFor(entry, `RESPONSE_${index + 1}`);
332
+ return {
333
+ responseIdentifier,
334
+ answers: textEntryAnswers(context.responseDeclMap.get(responseIdentifier), context),
335
+ };
336
+ });
337
+ return {
338
+ interactionType: "textEntry",
339
+ identifier: context.identifier,
340
+ title: context.title,
341
+ bodyHtml: bodyWithTextEntryPlaceholders(context.body, interactions, responseIdentifierFor),
342
+ responses,
343
+ };
344
+ }
345
+
346
+ function mapExtendedText(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
347
+ const responseIdentifier = responseIdentifierFor(interaction);
348
+ const declaration = context.responseDeclMap.get(responseIdentifier);
349
+ return {
350
+ interactionType: "extendedText",
351
+ identifier: context.identifier,
352
+ title: context.title,
353
+ bodyHtml: bodyWithoutInteraction(context.body, interaction),
354
+ responseIdentifier,
355
+ expectedLength: toNumber(attr(interaction, "expectedLength")),
356
+ expectedLines: toNumber(attr(interaction, "expectedLines")),
357
+ minStrings: toNumber(attr(interaction, "minStrings")),
358
+ maxStrings: toNumber(attr(interaction, "maxStrings")),
359
+ placeholderText: attr(interaction, "placeholderText") ?? undefined,
360
+ format: extendedTextFormat(attr(interaction, "format")),
361
+ responseBaseType: baseType(attr(declaration, "baseType") ?? attr(declaration, "base-type")),
362
+ responseCardinality: responseCardinality(attr(declaration, "cardinality")),
363
+ };
364
+ }
365
+
366
+ function mapInlineChoice(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
367
+ const interactions = findAllDescendantsByLocalName(context.body, "inlinechoiceinteraction");
368
+ const slots = interactions.map((entry, index): Qti3InlineChoiceSlot => {
369
+ const responseIdentifier = responseIdentifierFor(entry, `RESPONSE_${index + 1}`);
370
+ const declaration = context.responseDeclMap.get(responseIdentifier);
371
+ const correctResponse = responseValues(declaration)[0];
372
+ return {
373
+ responseIdentifier,
374
+ shuffle: attr(entry, "shuffle") === "true",
375
+ required: attr(entry, "required") === "true",
376
+ options: findAllDescendantsByLocalName(entry, "inlinechoice").map((choice, choiceIndex) => ({
377
+ identifier: normalizeIdentifier(attr(choice, "identifier"), `CHOICE_${choiceIndex + 1}`),
378
+ contentHtml: trusted(serializeChildren(choice)),
379
+ text: textOf(choice) || undefined,
380
+ fixed: attr(choice, "fixed") === "true",
381
+ })),
382
+ correctResponse: correctResponse ? normalizeIdentifier(correctResponse) : undefined,
383
+ };
384
+ });
385
+ return {
386
+ interactionType: "inlineChoice",
387
+ identifier: context.identifier,
388
+ title: context.title,
389
+ bodyHtml: bodyWithInlineChoicePlaceholders(context.body, interactions, responseIdentifierFor),
390
+ slots,
391
+ scoring: "all_or_nothing",
392
+ };
393
+ }
394
+
395
+ function mapHottext(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
396
+ const responseIdentifier = responseIdentifierFor(interaction);
397
+ const hottexts = findAllDescendantsByLocalName(interaction, "hottext");
398
+ const choices = hottexts.map(
399
+ (hottext, index): Qti3HottextChoice => ({
400
+ identifier: normalizeIdentifier(attr(hottext, "identifier"), `H${index + 1}`),
401
+ contentHtml: trusted(serializeChildren(hottext)),
402
+ text: textOf(hottext) || undefined,
403
+ }),
404
+ );
405
+ return {
406
+ interactionType: "hottext",
407
+ identifier: context.identifier,
408
+ title: context.title,
409
+ promptHtml: prompt(interaction),
410
+ bodyHtml: bodyWithHottextPlaceholders(interaction, hottexts),
411
+ responseIdentifier,
412
+ choices,
413
+ correctResponse: responseValues(context.responseDeclMap.get(responseIdentifier)).map((value) =>
414
+ normalizeIdentifier(value),
415
+ ),
416
+ minChoices: toNumber(attr(interaction, "minChoices")),
417
+ maxChoices: toNumber(attr(interaction, "maxChoices")),
418
+ };
419
+ }
420
+
421
+ function mapGapMatch(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
422
+ const responseIdentifier = responseIdentifierFor(interaction);
423
+ const choices: Qti3GapMatchChoice[] = findAllDescendantsByAnyLocalName(interaction, [
424
+ "gaptext",
425
+ "gapimg",
426
+ ]).map((choice, index) => gapChoice(choice, index));
427
+ const targets = findAllDescendantsByLocalName(interaction, "gap").map((gap, index) => ({
428
+ identifier: normalizeIdentifier(attr(gap, "identifier"), `GAP_${index + 1}`),
429
+ }));
430
+ return {
431
+ interactionType: "gapMatch",
432
+ identifier: context.identifier,
433
+ title: context.title,
434
+ promptHtml: prompt(interaction),
435
+ bodyHtml: bodyWithGapPlaceholders(interaction),
436
+ responseIdentifier,
437
+ choices,
438
+ targets,
439
+ correctResponse: pairValues(context.responseDeclMap.get(responseIdentifier)),
440
+ shuffle: attr(interaction, "shuffle") === "true",
441
+ minAssociations: toNumber(attr(interaction, "minAssociations")),
442
+ maxAssociations: toNumber(attr(interaction, "maxAssociations")),
443
+ scoring: hasMapping(context.responseDeclMap.get(responseIdentifier))
444
+ ? "map_response"
445
+ : "match_correct",
446
+ };
447
+ }
448
+
449
+ function mapHotspot(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
450
+ const responseIdentifier = responseIdentifierFor(interaction);
451
+ const object = findDescendantByLocalName(interaction, "object");
452
+ const choices = findAllDescendantsByLocalName(interaction, "hotspotchoice").map(
453
+ (choice, index) => ({
454
+ identifier: normalizeIdentifier(attr(choice, "identifier"), `H${index + 1}`),
455
+ shape: hotspotShape(attr(choice, "shape")),
456
+ coords: attr(choice, "coords") ?? "",
457
+ }),
458
+ );
459
+ return {
460
+ interactionType: "hotspot",
461
+ identifier: context.identifier,
462
+ title: context.title,
463
+ promptHtml: prompt(interaction),
464
+ responseIdentifier,
465
+ object: graphicObject(
466
+ object,
467
+ choices.map((choice) => choice.coords),
468
+ ),
469
+ choices,
470
+ correctResponse: responseValues(context.responseDeclMap.get(responseIdentifier)).map((value) =>
471
+ normalizeIdentifier(value),
472
+ ),
473
+ minChoices: toNumber(attr(interaction, "minChoices")),
474
+ maxChoices: toNumber(attr(interaction, "maxChoices")),
475
+ };
476
+ }
477
+
478
+ function mapGraphicOrder(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
479
+ const responseIdentifier = responseIdentifierFor(interaction);
480
+ const object = findDescendantByLocalName(interaction, "object");
481
+ const hotspots = findAllDescendantsByLocalName(interaction, "hotspotchoice").map(
482
+ (hotspot, index) => ({
483
+ identifier: normalizeIdentifier(attr(hotspot, "identifier"), `H${index + 1}`),
484
+ shape: hotspotShape(attr(hotspot, "shape")),
485
+ coords: attr(hotspot, "coords") ?? "",
486
+ hotspotLabel: attr(hotspot, "hotspotLabel") ?? attr(hotspot, "hotspot-label") ?? undefined,
487
+ }),
488
+ );
489
+ return {
490
+ interactionType: "graphicOrder",
491
+ identifier: context.identifier,
492
+ title: context.title,
493
+ bodyHtml: bodyWithoutInteraction(context.body, interaction),
494
+ promptHtml: prompt(interaction),
495
+ responseIdentifier,
496
+ object: graphicObject(
497
+ object,
498
+ hotspots.map((hotspot) => hotspot.coords),
499
+ ),
500
+ hotspots,
501
+ correctOrder: orderedIdentifierValues(context.responseDeclMap.get(responseIdentifier)),
502
+ minChoices: toNumber(attr(interaction, "minChoices")),
503
+ maxChoices: toNumber(attr(interaction, "maxChoices")),
504
+ };
505
+ }
506
+
507
+ function mapGraphicAssociate(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
508
+ const responseIdentifier = responseIdentifierFor(interaction);
509
+ const object = findDescendantByLocalName(interaction, "object");
510
+ const hotspots = findAllDescendantsByLocalName(interaction, "associablehotspot").map(
511
+ (hotspot, index) => ({
512
+ identifier: normalizeIdentifier(attr(hotspot, "identifier"), `H${index + 1}`),
513
+ shape: hotspotShape(attr(hotspot, "shape")),
514
+ coords: attr(hotspot, "coords") ?? "",
515
+ matchMax: toNumber(attr(hotspot, "matchMax")),
516
+ }),
517
+ );
518
+ return {
519
+ interactionType: "graphicAssociate",
520
+ identifier: context.identifier,
521
+ title: context.title,
522
+ bodyHtml: bodyWithoutInteraction(context.body, interaction),
523
+ promptHtml: prompt(interaction),
524
+ responseIdentifier,
525
+ object: graphicObject(
526
+ object,
527
+ hotspots.map((hotspot) => hotspot.coords),
528
+ ),
529
+ hotspots,
530
+ correctResponse: pairValues(context.responseDeclMap.get(responseIdentifier)),
531
+ minAssociations: toNumber(attr(interaction, "minAssociations")),
532
+ maxAssociations: toNumber(attr(interaction, "maxAssociations")),
533
+ scoring: hasMapping(context.responseDeclMap.get(responseIdentifier))
534
+ ? "map_response"
535
+ : "match_correct",
536
+ };
537
+ }
538
+
539
+ function mapGraphicGapMatch(interaction: XmlElement, context: Qti2Context): Qti3AuthoringItem {
540
+ const responseIdentifier = responseIdentifierFor(interaction);
541
+ const object = findDescendantByLocalName(interaction, "object");
542
+ const choices = findAllDescendantsByAnyLocalName(interaction, ["gaptext", "gapimg"]).map(
543
+ (choice, index): Qti3GraphicGapChoice => graphicGapChoice(choice, index),
544
+ );
545
+ const targets: Qti3GraphicGapTarget[] = findAllDescendantsByLocalName(
546
+ interaction,
547
+ "associablehotspot",
548
+ ).map((target, index) => ({
549
+ targetType: "hotspot",
550
+ identifier: normalizeIdentifier(attr(target, "identifier"), `T${index + 1}`),
551
+ shape: hotspotShape(attr(target, "shape")),
552
+ coords: attr(target, "coords") ?? "",
553
+ matchMax: toNumber(attr(target, "matchMax")),
554
+ }));
555
+ const targetCoords = targets.flatMap((target) =>
556
+ target.targetType === "inlineGap" ? [] : [target.coords],
557
+ );
558
+ return {
559
+ interactionType: "graphicGapMatch",
560
+ identifier: context.identifier,
561
+ title: context.title,
562
+ promptHtml: prompt(interaction),
563
+ responseIdentifier,
564
+ object: graphicObject(object, targetCoords),
565
+ choices,
566
+ targets,
567
+ correctResponse: pairValues(context.responseDeclMap.get(responseIdentifier)),
568
+ minAssociations: toNumber(attr(interaction, "minAssociations")),
569
+ maxAssociations: toNumber(attr(interaction, "maxAssociations")),
570
+ scoring: hasMapping(context.responseDeclMap.get(responseIdentifier))
571
+ ? "map_response"
572
+ : "match_correct",
573
+ };
574
+ }
575
+
576
+ function responseIdentifierFor(interaction: XmlElement, fallback = "RESPONSE"): string {
577
+ return normalizeIdentifier(
578
+ attr(interaction, "responseIdentifier") ?? attr(interaction, "response-identifier"),
579
+ fallback,
580
+ );
581
+ }
582
+
583
+ function textEntryAnswers(declaration: XmlElement | undefined, context: Qti2Context) {
584
+ const correctValues = responseValues(declaration);
585
+ repairOrError({
586
+ needed: !correctValues.length,
587
+ context,
588
+ code: "qti2_text_entry_correct_response_missing",
589
+ message: "QTI 2.x text entry interaction has no correct text value.",
590
+ repairMessage:
591
+ "QTI 2.x text entry response did not declare a correct value; using an empty answer.",
592
+ });
593
+ if (!correctValues.length) return [{ value: "", score: 1, caseSensitive: false }];
594
+ return correctValues.map((value) => ({ value, score: 1, caseSensitive: false }));
595
+ }
596
+
597
+ function repairOrError(input: {
598
+ readonly needed: boolean;
599
+ readonly context: Qti2Context;
600
+ readonly code: string;
601
+ readonly message: string;
602
+ readonly repairMessage: string;
603
+ }): void {
604
+ if (!input.needed) return;
605
+ if (input.context.options.repairPolicy === "safe") {
606
+ input.context.diagnostics.push(
607
+ diagnostic(`${input.code}_repaired`, "warning", input.repairMessage, {
608
+ path: input.context.path,
609
+ sourceFormat: input.context.sourceFormat,
610
+ }),
611
+ );
612
+ return;
613
+ }
614
+ throw new Qti2MigrationBlocked([
615
+ diagnostic(input.code, "error", input.message, {
616
+ path: input.context.path,
617
+ sourceFormat: input.context.sourceFormat,
618
+ }),
619
+ ]);
620
+ }
621
+
622
+ class Qti2MigrationBlocked extends Error {
623
+ constructor(readonly diagnostics: readonly QtiMigrationDiagnostic[]) {
624
+ super("QTI 2.x migration blocked by strict policy.");
625
+ }
626
+ }
627
+
628
+ function baseType(value: string | null): "string" | "integer" | "float" {
629
+ const normalized = value?.toLowerCase();
630
+ return normalized === "integer" || normalized === "float" ? normalized : "string";
631
+ }
632
+
633
+ function responseCardinality(value: string | null): "single" | "multiple" | "ordered" {
634
+ const normalized = value?.toLowerCase();
635
+ return normalized === "multiple" || normalized === "ordered" ? normalized : "single";
636
+ }
637
+
638
+ function extendedTextFormat(value: string | null): "plain" | "preformatted" | "xhtml" {
639
+ return value === "preformatted" || value === "xhtml" ? value : "plain";
640
+ }
641
+
642
+ export function itemTitleFromXml(xml: string): string {
643
+ const doc = parseXml(xml, "item-title");
644
+ const root = doc.documentElement;
645
+ return (
646
+ attr(root, "title")?.trim() ||
647
+ stripTags(serializeChildren(root)).slice(0, 40) ||
648
+ "Imported Item"
649
+ );
650
+ }
@@ -0,0 +1,41 @@
1
+ import { normalizeIdentifier } from "./text.js";
2
+ import {
3
+ findAllDescendantsByLocalName,
4
+ findDescendantByLocalName,
5
+ textOf,
6
+ type XmlElement,
7
+ } from "./xml.js";
8
+
9
+ export function responseValues(declaration: XmlElement | undefined): string[] {
10
+ if (!declaration) return [];
11
+ const correct = findDescendantByLocalName(declaration, "correctresponse");
12
+ const source = correct ?? declaration;
13
+ return findAllDescendantsByLocalName(source, "value")
14
+ .map((value) => textOf(value))
15
+ .filter(Boolean);
16
+ }
17
+
18
+ export function pairValues(
19
+ declaration: XmlElement | undefined,
20
+ ): { sourceIdentifier: string; targetIdentifier: string }[] {
21
+ return responseValues(declaration)
22
+ .map((value) => {
23
+ const [sourceIdentifier = "", targetIdentifier = ""] = value.split(/\s+/);
24
+ return {
25
+ sourceIdentifier: normalizeIdentifier(sourceIdentifier),
26
+ targetIdentifier: normalizeIdentifier(targetIdentifier),
27
+ };
28
+ })
29
+ .filter((pair) => pair.sourceIdentifier && pair.targetIdentifier);
30
+ }
31
+
32
+ export function orderedIdentifierValues(declaration: XmlElement | undefined): string[] {
33
+ return responseValues(declaration)
34
+ .flatMap((value) => value.split(/\s+/))
35
+ .map((value) => normalizeIdentifier(value))
36
+ .filter(Boolean);
37
+ }
38
+
39
+ export function hasMapping(declaration: XmlElement | undefined): boolean {
40
+ return Boolean(findDescendantByLocalName(declaration, "mapping"));
41
+ }