@longsightgroup/qti3-migrator 0.9.6 → 0.9.8

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 (60) hide show
  1. package/README.md +54 -0
  2. package/dist/assets.d.ts +10 -0
  3. package/dist/assets.d.ts.map +1 -0
  4. package/dist/assets.js +19 -0
  5. package/dist/assets.js.map +1 -0
  6. package/dist/diagnostics.d.ts +2 -1
  7. package/dist/diagnostics.d.ts.map +1 -1
  8. package/dist/diagnostics.js +7 -0
  9. package/dist/diagnostics.js.map +1 -1
  10. package/dist/identifiers.d.ts +3 -0
  11. package/dist/identifiers.d.ts.map +1 -0
  12. package/dist/identifiers.js +11 -0
  13. package/dist/identifiers.js.map +1 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +24 -133
  17. package/dist/index.js.map +1 -1
  18. package/dist/item-finalize.d.ts +12 -0
  19. package/dist/item-finalize.d.ts.map +1 -0
  20. package/dist/item-finalize.js +93 -0
  21. package/dist/item-finalize.js.map +1 -0
  22. package/dist/item-hrefs.d.ts +2 -0
  23. package/dist/item-hrefs.d.ts.map +1 -0
  24. package/dist/item-hrefs.js +19 -0
  25. package/dist/item-hrefs.js.map +1 -0
  26. package/dist/migrate-items.d.ts +6 -0
  27. package/dist/migrate-items.d.ts.map +1 -0
  28. package/dist/migrate-items.js +31 -0
  29. package/dist/migrate-items.js.map +1 -0
  30. package/dist/package-result.d.ts +12 -0
  31. package/dist/package-result.d.ts.map +1 -0
  32. package/dist/package-result.js +109 -0
  33. package/dist/package-result.js.map +1 -0
  34. package/dist/paths.d.ts +4 -0
  35. package/dist/paths.d.ts.map +1 -0
  36. package/dist/paths.js +16 -0
  37. package/dist/paths.js.map +1 -0
  38. package/dist/resource-migration.d.ts +4 -0
  39. package/dist/resource-migration.d.ts.map +1 -0
  40. package/dist/resource-migration.js +128 -0
  41. package/dist/resource-migration.js.map +1 -0
  42. package/dist/source.d.ts +5 -0
  43. package/dist/source.d.ts.map +1 -1
  44. package/dist/source.js +26 -6
  45. package/dist/source.js.map +1 -1
  46. package/dist/types.d.ts +43 -1
  47. package/dist/types.d.ts.map +1 -1
  48. package/package.json +3 -3
  49. package/src/assets.ts +23 -0
  50. package/src/diagnostics.ts +12 -0
  51. package/src/identifiers.ts +12 -0
  52. package/src/index.ts +45 -176
  53. package/src/item-finalize.ts +133 -0
  54. package/src/item-hrefs.ts +23 -0
  55. package/src/migrate-items.ts +39 -0
  56. package/src/package-result.ts +157 -0
  57. package/src/paths.ts +33 -0
  58. package/src/resource-migration.ts +197 -0
  59. package/src/source.ts +40 -6
  60. package/src/types.ts +53 -1
package/src/index.ts CHANGED
@@ -1,34 +1,30 @@
1
- import { parseQtiXml, validateAssessmentItem, type QtiDiagnostic } from "@longsightgroup/qti3-core";
2
- import {
3
- qti3TrustedXmlFragment,
4
- writeQti3AssessmentItemResult,
5
- type Qti3AuthoringItem,
6
- } from "@longsightgroup/qti3-writer";
7
- import { diagnostic, hasErrors } from "./diagnostics.js";
1
+ import { diagnostic } from "./diagnostics.js";
2
+ import { migrationPathIdentifier } from "./identifiers.js";
3
+ import { finalizeItemResult, finalizeItemResults } from "./item-finalize.js";
4
+ import { migrateItemXml } from "./migrate-items.js";
5
+ import { migrationPackageAssets } from "./assets.js";
8
6
  import { resolveOptions } from "./options.js";
9
- import { migrateQti12Xml } from "./qti12-item.js";
10
- import { itemTitleFromXml, migrateQti2ItemXml } from "./qti2-item.js";
7
+ import { migrationResultToPackage } from "./package-result.js";
8
+ import { migrateQtiResourceToQti3 } from "./resource-migration.js";
11
9
  import {
12
10
  detectMigrationSource,
13
11
  detectQtiMigrationSource,
14
12
  parseLegacyManifest,
15
13
  readMigrationSource,
16
- type MigrationEntry,
17
14
  type MigrationSource,
18
15
  } from "./source.js";
19
16
  import type {
20
- QtiMigrationAsset,
21
17
  QtiMigrationDetectionResult,
22
18
  QtiMigrationDiagnostic,
23
19
  QtiMigrationItemResult,
24
20
  QtiMigrationOptions,
25
21
  QtiMigrationPart,
26
22
  QtiMigrationResult,
27
- QtiMigrationSourceFormat,
28
23
  QtiMigrationSourceInput,
24
+ QtiPackageMigrationResult,
29
25
  } from "./types.js";
30
26
 
31
- export { detectQtiMigrationSource };
27
+ export { detectQtiMigrationSource, migrateQtiResourceToQti3 };
32
28
  export type {
33
29
  QtiMigrationAsset,
34
30
  QtiMigrationDetectionResult,
@@ -36,12 +32,17 @@ export type {
36
32
  QtiMigrationDiagnosticSeverity,
37
33
  QtiMigrationItemResult,
38
34
  QtiMigrationOptions,
35
+ QtiPackageMigrationResult,
39
36
  QtiMigrationPart,
37
+ QtiMigrationResourceEntry,
38
+ QtiMigrationResourceInput,
40
39
  QtiMigrationRepairPolicy,
41
40
  QtiMigrationResult,
42
41
  QtiMigrationSourceFormat,
43
42
  QtiMigrationSourceInput,
44
43
  QtiMigrationUnsupportedPolicy,
44
+ QtiResourceMigrationResult,
45
+ QtiResourceMigrationStatus,
45
46
  } from "./types.js";
46
47
 
47
48
  export async function migrateQtiToQti3(
@@ -77,6 +78,14 @@ export async function migrateQtiToQti3(
77
78
  return Promise.resolve(migrated);
78
79
  }
79
80
 
81
+ export async function migrateQtiToQti3Package(
82
+ input: QtiMigrationSourceInput,
83
+ options: QtiMigrationOptions = {},
84
+ ): Promise<QtiPackageMigrationResult> {
85
+ const migration = await migrateQtiToQti3(input, options);
86
+ return migrationResultToPackage(migration);
87
+ }
88
+
80
89
  export function migrateQtiItemToQti3(
81
90
  input: QtiMigrationSourceInput,
82
91
  options: QtiMigrationOptions = {},
@@ -168,6 +177,16 @@ function migratePackageSource(
168
177
  );
169
178
  }
170
179
  const entriesByPath = new Map(source.entries.map((entry) => [entry.path, entry]));
180
+ const assetHrefsByItemHref = new Map<string, readonly string[]>();
181
+ for (const resource of manifest.resources) {
182
+ if (!resource.href) continue;
183
+ assetHrefsByItemHref.set(
184
+ resource.href,
185
+ resource.files.filter(
186
+ (file) => file !== resource.href && !file.toLowerCase().endsWith(".xml"),
187
+ ),
188
+ );
189
+ }
171
190
  const itemHrefs = manifest.itemHrefs.length
172
191
  ? manifest.itemHrefs
173
192
  : source.entries
@@ -180,7 +199,7 @@ function migratePackageSource(
180
199
  const entry = entriesByPath.get(href) ?? entriesByPath.get(href.replace(/^\.\//, ""));
181
200
  if (!entry?.text) {
182
201
  items.push({
183
- identifier: pathIdentifier(href),
202
+ identifier: migrationPathIdentifier(href),
184
203
  title: href,
185
204
  href,
186
205
  diagnostics: [
@@ -192,9 +211,13 @@ function migratePackageSource(
192
211
  });
193
212
  continue;
194
213
  }
195
- const migratedItems = migrateItemXml(entry.text, href, sourceFormat, options);
214
+ const migratedItems = finalizeItemResults(
215
+ migrateItemXml(entry.text, href, sourceFormat, options),
216
+ href,
217
+ options,
218
+ );
196
219
  for (const item of migratedItems) {
197
- items.push(finalizeItemResult(item, `${item.identifier}.xml`, options));
220
+ items.push({ ...item, assetHrefs: assetHrefsByItemHref.get(href) ?? [] });
198
221
  }
199
222
  }
200
223
  return {
@@ -202,7 +225,7 @@ function migratePackageSource(
202
225
  sourceFormat,
203
226
  parts: [partFromItems("PART_1", manifest.title, items)],
204
227
  items,
205
- assets: packageAssets(source.entries),
228
+ assets: migrationPackageAssets(source.entries),
206
229
  diagnostics,
207
230
  };
208
231
  }
@@ -214,12 +237,12 @@ function migrateItemSource(
214
237
  ): QtiMigrationResult {
215
238
  const sourceFormat = detection.sourceFormat!;
216
239
  const xml = source.xml ?? "";
217
- const migratedItems = migrateItemXml(
218
- xml,
219
- source.filename ?? "item.xml",
220
- sourceFormat,
240
+ const href = source.filename ?? "item.xml";
241
+ const migratedItems = finalizeItemResults(
242
+ migrateItemXml(xml, href, sourceFormat, options),
243
+ href,
221
244
  options,
222
- ).map((item) => finalizeItemResult(item, `${item.identifier}.xml`, options));
245
+ );
223
246
  return {
224
247
  title: migratedItems[0]?.title ?? source.filename ?? "Imported QTI Item",
225
248
  sourceFormat,
@@ -230,138 +253,6 @@ function migrateItemSource(
230
253
  };
231
254
  }
232
255
 
233
- function migrateItemXml(
234
- xml: string,
235
- path: string,
236
- sourceFormat: QtiMigrationSourceFormat,
237
- options: ReturnType<typeof resolveOptions>,
238
- ): readonly PendingMigrationItem[] {
239
- if (sourceFormat === "qti12") {
240
- return migrateQti12Xml(xml, path, options).map((result, index) => ({
241
- identifier: result.authoringItem?.identifier ?? `ITEM_${index + 1}`,
242
- title: result.authoringItem?.title ?? itemTitleFromXmlSafe(xml),
243
- authoringItem: result.authoringItem,
244
- diagnostics: result.diagnostics,
245
- }));
246
- }
247
- const result = migrateQti2ItemXml(xml, path, sourceFormat, options);
248
- return [
249
- {
250
- identifier: result.authoringItem?.identifier ?? pathIdentifier(path),
251
- title: result.authoringItem?.title ?? itemTitleFromXmlSafe(xml),
252
- authoringItem: result.authoringItem,
253
- diagnostics: result.diagnostics,
254
- },
255
- ];
256
- }
257
-
258
- interface PendingMigrationItem {
259
- readonly identifier: string;
260
- readonly title: string;
261
- readonly authoringItem?: Qti3AuthoringItem | undefined;
262
- readonly diagnostics: readonly QtiMigrationDiagnostic[];
263
- }
264
-
265
- function finalizeItemResult(
266
- pending: PendingMigrationItem,
267
- href: string,
268
- options: ReturnType<typeof resolveOptions>,
269
- ): QtiMigrationItemResult {
270
- const diagnostics = [...pending.diagnostics];
271
- if (pending.authoringItem && !hasErrors(diagnostics)) {
272
- const writer = writeQti3AssessmentItemResult(pending.authoringItem);
273
- if (writer.ok) {
274
- diagnostics.push(...validateWrittenXml(writer.xml));
275
- return {
276
- identifier: pending.identifier,
277
- title: pending.title,
278
- href,
279
- authoringItem: pending.authoringItem,
280
- xml: writer.xml,
281
- diagnostics,
282
- };
283
- }
284
- diagnostics.push({
285
- code: "writer_diagnostics",
286
- severity: "error",
287
- message: "QTI 3 writer rejected migrated authoring item.",
288
- writerDiagnostics: writer.diagnostics,
289
- });
290
- }
291
-
292
- if (options.unsupportedPolicy === "stub") {
293
- const stub = stubItem(pending.identifier, pending.title);
294
- const writer = writeQti3AssessmentItemResult(stub);
295
- return {
296
- identifier: pending.identifier,
297
- title: pending.title,
298
- href,
299
- authoringItem: stub,
300
- xml: writer.ok ? writer.xml : undefined,
301
- diagnostics: [
302
- ...diagnostics,
303
- diagnostic(
304
- "unsupported_item_stubbed",
305
- "warning",
306
- "Unsupported item migrated as an extended-text review stub.",
307
- ),
308
- ],
309
- };
310
- }
311
-
312
- return {
313
- identifier: pending.identifier,
314
- title: pending.title,
315
- href,
316
- authoringItem: options.unsupportedPolicy === "skip" ? undefined : pending.authoringItem,
317
- diagnostics,
318
- };
319
- }
320
-
321
- function validateWrittenXml(xml: string): QtiMigrationDiagnostic[] {
322
- const parsed = parseQtiXml(xml);
323
- const diagnostics: QtiMigrationDiagnostic[] = [];
324
- if (!parsed.ok || !parsed.document) {
325
- diagnostics.push(...coreDiagnostics("core_parse", parsed.diagnostics));
326
- return diagnostics;
327
- }
328
- const validation = validateAssessmentItem(parsed.document);
329
- if (!validation.ok) diagnostics.push(...coreDiagnostics("core_validate", validation.diagnostics));
330
- return diagnostics;
331
- }
332
-
333
- function coreDiagnostics(
334
- prefix: string,
335
- diagnostics: readonly QtiDiagnostic[],
336
- ): QtiMigrationDiagnostic[] {
337
- return diagnostics.map((entry) =>
338
- diagnostic(
339
- `${prefix}.${entry.code}`,
340
- entry.severity === "error" ? "error" : "warning",
341
- entry.message,
342
- ),
343
- );
344
- }
345
-
346
- function stubItem(identifier: string, title: string): Qti3AuthoringItem {
347
- return {
348
- interactionType: "extendedText",
349
- identifier,
350
- title,
351
- bodyHtml: qti3TrustedXmlFragment("<p>This item requires manual migration review.</p>"),
352
- responseIdentifier: "RESPONSE",
353
- responseBaseType: "string",
354
- responseCardinality: "single",
355
- expectedLines: 6,
356
- };
357
- }
358
-
359
- function packageAssets(entries: readonly MigrationEntry[]): QtiMigrationAsset[] {
360
- return entries
361
- .filter((entry) => !entry.path.toLowerCase().endsWith(".xml"))
362
- .map((entry) => ({ path: entry.path, data: entry.bytes, mediaType: mediaType(entry.path) }));
363
- }
364
-
365
256
  function partFromItems(
366
257
  identifier: string,
367
258
  title: string,
@@ -369,25 +260,3 @@ function partFromItems(
369
260
  ): QtiMigrationPart {
370
261
  return { identifier, title, itemHrefs: items.map((item) => item.href) };
371
262
  }
372
-
373
- function pathIdentifier(path: string): string {
374
- return path.replace(/\.[^.]+$/, "").replace(/[^A-Za-z0-9_]/g, "_") || "ITEM";
375
- }
376
-
377
- function itemTitleFromXmlSafe(xml: string): string {
378
- try {
379
- return itemTitleFromXml(xml);
380
- } catch {
381
- return "Imported Item";
382
- }
383
- }
384
-
385
- function mediaType(path: string): string | undefined {
386
- if (/\.png$/i.test(path)) return "image/png";
387
- if (/\.jpe?g$/i.test(path)) return "image/jpeg";
388
- if (/\.gif$/i.test(path)) return "image/gif";
389
- if (/\.svg$/i.test(path)) return "image/svg+xml";
390
- if (/\.mp3$/i.test(path)) return "audio/mpeg";
391
- if (/\.mp4$/i.test(path)) return "video/mp4";
392
- return undefined;
393
- }
@@ -0,0 +1,133 @@
1
+ import { parseQtiXml, validateAssessmentItem, type QtiDiagnostic } from "@longsightgroup/qti3-core";
2
+ import {
3
+ qti3TrustedXmlFragment,
4
+ writeQti3AssessmentItemResult,
5
+ type Qti3AuthoringItem,
6
+ } from "@longsightgroup/qti3-writer";
7
+
8
+ import { diagnostic, hasErrors } from "./diagnostics.js";
9
+ import { uniqueSiblingItemHref } from "./item-hrefs.js";
10
+ import type { QtiMigrationDiagnostic, QtiMigrationItemResult } from "./types.js";
11
+ import type { ResolvedQtiMigrationOptions } from "./types.js";
12
+
13
+ export interface PendingMigrationItem {
14
+ readonly identifier: string;
15
+ readonly title: string;
16
+ readonly authoringItem?: Qti3AuthoringItem | undefined;
17
+ readonly diagnostics: readonly QtiMigrationDiagnostic[];
18
+ }
19
+
20
+ export function finalizeItemResults(
21
+ pendingItems: readonly PendingMigrationItem[],
22
+ sourceHref: string,
23
+ options: ResolvedQtiMigrationOptions,
24
+ ): readonly QtiMigrationItemResult[] {
25
+ if (pendingItems.length <= 1) {
26
+ return pendingItems.map((item) => finalizeItemResult(item, sourceHref, options));
27
+ }
28
+
29
+ const identifiers = new Set<string>();
30
+ const paths = new Set<string>();
31
+ return pendingItems.map((item, index) => {
32
+ const identifier = item.identifier.trim();
33
+ const uniqueIdentifier = identifier && !identifiers.has(identifier);
34
+ if (uniqueIdentifier) identifiers.add(identifier);
35
+ const suffix = uniqueIdentifier ? identifier : `ITEM_${index + 1}`;
36
+ const href = uniqueSiblingItemHref(sourceHref, suffix, paths);
37
+ return finalizeItemResult(item, href, options);
38
+ });
39
+ }
40
+
41
+ export function finalizeItemResult(
42
+ pending: PendingMigrationItem,
43
+ href: string,
44
+ options: ResolvedQtiMigrationOptions,
45
+ ): QtiMigrationItemResult {
46
+ const diagnostics = [...pending.diagnostics];
47
+ if (pending.authoringItem && !hasErrors(diagnostics)) {
48
+ const writer = writeQti3AssessmentItemResult(pending.authoringItem);
49
+ if (writer.ok) {
50
+ diagnostics.push(...validateWrittenXml(writer.xml));
51
+ return {
52
+ identifier: pending.identifier,
53
+ title: pending.title,
54
+ href,
55
+ authoringItem: pending.authoringItem,
56
+ xml: writer.xml,
57
+ diagnostics,
58
+ };
59
+ }
60
+ diagnostics.push({
61
+ code: "writer_diagnostics",
62
+ severity: "error",
63
+ message: "QTI 3 writer rejected migrated authoring item.",
64
+ writerDiagnostics: writer.diagnostics,
65
+ });
66
+ }
67
+
68
+ if (options.unsupportedPolicy === "stub") {
69
+ const stub = stubItem(pending.identifier, pending.title);
70
+ const writer = writeQti3AssessmentItemResult(stub);
71
+ return {
72
+ identifier: pending.identifier,
73
+ title: pending.title,
74
+ href,
75
+ authoringItem: stub,
76
+ xml: writer.ok ? writer.xml : undefined,
77
+ diagnostics: [
78
+ ...diagnostics,
79
+ diagnostic(
80
+ "unsupported_item_stubbed",
81
+ "warning",
82
+ "Unsupported item migrated as an extended-text review stub.",
83
+ ),
84
+ ],
85
+ };
86
+ }
87
+
88
+ return {
89
+ identifier: pending.identifier,
90
+ title: pending.title,
91
+ href,
92
+ authoringItem: options.unsupportedPolicy === "skip" ? undefined : pending.authoringItem,
93
+ diagnostics,
94
+ };
95
+ }
96
+
97
+ function validateWrittenXml(xml: string): QtiMigrationDiagnostic[] {
98
+ const parsed = parseQtiXml(xml);
99
+ const diagnostics: QtiMigrationDiagnostic[] = [];
100
+ if (!parsed.ok || !parsed.document) {
101
+ diagnostics.push(...coreDiagnostics("core_parse", parsed.diagnostics));
102
+ return diagnostics;
103
+ }
104
+ const validation = validateAssessmentItem(parsed.document);
105
+ if (!validation.ok) diagnostics.push(...coreDiagnostics("core_validate", validation.diagnostics));
106
+ return diagnostics;
107
+ }
108
+
109
+ function coreDiagnostics(
110
+ prefix: string,
111
+ diagnostics: readonly QtiDiagnostic[],
112
+ ): QtiMigrationDiagnostic[] {
113
+ return diagnostics.map((entry) =>
114
+ diagnostic(
115
+ `${prefix}.${entry.code}`,
116
+ entry.severity === "error" ? "error" : "warning",
117
+ entry.message,
118
+ ),
119
+ );
120
+ }
121
+
122
+ function stubItem(identifier: string, title: string): Qti3AuthoringItem {
123
+ return {
124
+ interactionType: "extendedText",
125
+ identifier,
126
+ title,
127
+ bodyHtml: qti3TrustedXmlFragment("<p>This item requires manual migration review.</p>"),
128
+ responseIdentifier: "RESPONSE",
129
+ responseBaseType: "string",
130
+ responseCardinality: "single",
131
+ expectedLines: 6,
132
+ };
133
+ }
@@ -0,0 +1,23 @@
1
+ import { migrationPathIdentifier } from "./identifiers.js";
2
+
3
+ export function uniqueSiblingItemHref(
4
+ sourceHref: string,
5
+ suffix: string,
6
+ usedPaths: Set<string>,
7
+ ): string {
8
+ const slashIndex = sourceHref.lastIndexOf("/");
9
+ const directory = slashIndex >= 0 ? sourceHref.slice(0, slashIndex + 1) : "";
10
+ const fileName = slashIndex >= 0 ? sourceHref.slice(slashIndex + 1) : sourceHref;
11
+ const dotIndex = fileName.toLowerCase().endsWith(".xml")
12
+ ? fileName.length - 4
13
+ : fileName.lastIndexOf(".");
14
+ const baseName = dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName || "item";
15
+ const extension = dotIndex > 0 ? fileName.slice(dotIndex) : ".xml";
16
+ const safeSuffix = migrationPathIdentifier(suffix);
17
+ let candidate = `${directory}${baseName}_${safeSuffix}${extension}`;
18
+ for (let index = 2; usedPaths.has(candidate); index += 1) {
19
+ candidate = `${directory}${baseName}_${safeSuffix}_${index}${extension}`;
20
+ }
21
+ usedPaths.add(candidate);
22
+ return candidate;
23
+ }
@@ -0,0 +1,39 @@
1
+ import { migrationPathIdentifier } from "./identifiers.js";
2
+ import type { PendingMigrationItem } from "./item-finalize.js";
3
+ import { migrateQti12Xml } from "./qti12-item.js";
4
+ import { itemTitleFromXml, migrateQti2ItemXml } from "./qti2-item.js";
5
+ import type { QtiMigrationSourceFormat } from "./types.js";
6
+ import type { ResolvedQtiMigrationOptions } from "./types.js";
7
+
8
+ export function migrateItemXml(
9
+ xml: string,
10
+ path: string,
11
+ sourceFormat: QtiMigrationSourceFormat,
12
+ options: ResolvedQtiMigrationOptions,
13
+ ): readonly PendingMigrationItem[] {
14
+ if (sourceFormat === "qti12") {
15
+ return migrateQti12Xml(xml, path, options).map((result, index) => ({
16
+ identifier: result.authoringItem?.identifier ?? `ITEM_${index + 1}`,
17
+ title: result.authoringItem?.title ?? itemTitleFromXmlSafe(xml),
18
+ authoringItem: result.authoringItem,
19
+ diagnostics: result.diagnostics,
20
+ }));
21
+ }
22
+ const result = migrateQti2ItemXml(xml, path, sourceFormat, options);
23
+ return [
24
+ {
25
+ identifier: result.authoringItem?.identifier ?? migrationPathIdentifier(path),
26
+ title: result.authoringItem?.title ?? itemTitleFromXmlSafe(xml),
27
+ authoringItem: result.authoringItem,
28
+ diagnostics: result.diagnostics,
29
+ },
30
+ ];
31
+ }
32
+
33
+ export function itemTitleFromXmlSafe(xml: string): string {
34
+ try {
35
+ return itemTitleFromXml(xml);
36
+ } catch {
37
+ return "Imported Item";
38
+ }
39
+ }
@@ -0,0 +1,157 @@
1
+ import { detectPackageMediaType } from "@longsightgroup/qti3-core";
2
+ import type { Qti3PackageAsset, Qti3PackageItem } from "@longsightgroup/qti3-writer";
3
+ import { writeQti3PackageFilesResult } from "@longsightgroup/qti3-writer";
4
+
5
+ import { diagnostic, hasErrors } from "./diagnostics.js";
6
+ import { migrationPackageIdentifier } from "./identifiers.js";
7
+ import type {
8
+ QtiMigrationAsset,
9
+ QtiMigrationDiagnostic,
10
+ QtiMigrationResourceEntry,
11
+ QtiMigrationResult,
12
+ QtiPackageMigrationResult,
13
+ } from "./types.js";
14
+
15
+ export type MigrationPackageEntriesResult =
16
+ | {
17
+ readonly ok: true;
18
+ readonly entries: readonly QtiMigrationResourceEntry[];
19
+ readonly diagnostics: readonly QtiMigrationDiagnostic[];
20
+ }
21
+ | { readonly ok: false; readonly diagnostics: readonly QtiMigrationDiagnostic[] };
22
+
23
+ export function migrationResultToPackage(result: QtiMigrationResult): QtiPackageMigrationResult {
24
+ const itemDiagnostics = result.items.flatMap((item) => item.diagnostics);
25
+ const assetDiagnostics: QtiMigrationDiagnostic[] = [];
26
+ const assetsByPath = migrationAssetsByPath(result.assets, assetDiagnostics);
27
+ const itemBuildDiagnostics: QtiMigrationDiagnostic[] = [];
28
+ const items = buildPackageItems(result, assetsByPath, itemBuildDiagnostics);
29
+ const diagnostics = [
30
+ ...result.diagnostics,
31
+ ...itemDiagnostics,
32
+ ...assetDiagnostics,
33
+ ...itemBuildDiagnostics,
34
+ ];
35
+
36
+ if (hasErrors(diagnostics)) return { ok: false, diagnostics };
37
+ return {
38
+ ok: true,
39
+ package: {
40
+ identifier: migrationPackageIdentifier(result.title),
41
+ title: result.title,
42
+ items,
43
+ },
44
+ diagnostics,
45
+ };
46
+ }
47
+
48
+ export function migrationResultToPackageEntries(
49
+ result: QtiMigrationResult,
50
+ ): MigrationPackageEntriesResult {
51
+ const packageResult = migrationResultToPackage(result);
52
+ if (!packageResult.ok) {
53
+ return { ok: false, diagnostics: packageResult.diagnostics };
54
+ }
55
+
56
+ const files = writeQti3PackageFilesResult(packageResult.package);
57
+ if (!files.ok) {
58
+ return {
59
+ ok: false,
60
+ diagnostics: [
61
+ ...packageResult.diagnostics,
62
+ {
63
+ code: "writer_diagnostics",
64
+ severity: "error",
65
+ message: "QTI 3 writer rejected migrated package files.",
66
+ writerDiagnostics: files.diagnostics,
67
+ },
68
+ ],
69
+ };
70
+ }
71
+
72
+ return {
73
+ ok: true,
74
+ entries: files.files.map((file) => ({
75
+ path: file.path,
76
+ data: file.data,
77
+ mediaType: detectPackageMediaType(file.path),
78
+ })),
79
+ diagnostics: packageResult.diagnostics,
80
+ };
81
+ }
82
+
83
+ function buildPackageItems(
84
+ result: QtiMigrationResult,
85
+ assetsByPath: ReadonlyMap<string, Qti3PackageAsset>,
86
+ diagnostics: QtiMigrationDiagnostic[],
87
+ ): Qti3PackageItem[] {
88
+ const items: Qti3PackageItem[] = [];
89
+ for (const item of result.items) {
90
+ if (!item.xml) {
91
+ diagnostics.push(
92
+ diagnostic("package_item_xml_missing", "error", "Migrated item has no QTI 3 XML.", {
93
+ path: item.href,
94
+ sourceFormat: result.sourceFormat,
95
+ }),
96
+ );
97
+ continue;
98
+ }
99
+
100
+ items.push({
101
+ kind: "xml",
102
+ path: item.href,
103
+ identifier: item.identifier,
104
+ xml: item.xml,
105
+ assets: packageItemAssets(item.assetHrefs ?? [], assetsByPath, diagnostics),
106
+ });
107
+ }
108
+ return items;
109
+ }
110
+
111
+ function migrationAssetsByPath(
112
+ assets: readonly QtiMigrationAsset[],
113
+ diagnostics: QtiMigrationDiagnostic[],
114
+ ): ReadonlyMap<string, Qti3PackageAsset> {
115
+ const assetsByPath = new Map<string, Qti3PackageAsset>();
116
+ for (const asset of assets) {
117
+ if (!asset.data) {
118
+ diagnostics.push(
119
+ diagnostic("package_asset_data_missing", "error", "Migrated package asset has no data.", {
120
+ path: asset.path,
121
+ }),
122
+ );
123
+ continue;
124
+ }
125
+ assetsByPath.set(asset.path, {
126
+ path: asset.path,
127
+ data: asset.data,
128
+ });
129
+ }
130
+ return assetsByPath;
131
+ }
132
+
133
+ function packageItemAssets(
134
+ hrefs: readonly string[],
135
+ assetsByPath: ReadonlyMap<string, Qti3PackageAsset>,
136
+ diagnostics: QtiMigrationDiagnostic[],
137
+ ): readonly Qti3PackageAsset[] {
138
+ const assets: Qti3PackageAsset[] = [];
139
+ for (const href of hrefs) {
140
+ const asset = assetsByPath.get(href);
141
+ if (!asset) {
142
+ diagnostics.push(
143
+ diagnostic(
144
+ "package_asset_missing",
145
+ "error",
146
+ "Migrated package item references missing asset.",
147
+ {
148
+ path: href,
149
+ },
150
+ ),
151
+ );
152
+ continue;
153
+ }
154
+ assets.push(asset);
155
+ }
156
+ return assets;
157
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { normalizePackagePath, type QtiDiagnostic } from "@longsightgroup/qti3-core";
2
+
3
+ import { diagnostic } from "./diagnostics.js";
4
+ import type { QtiMigrationDiagnostic } from "./types.js";
5
+
6
+ /** Normalize a host resource path and map core package-path diagnostics into migrator codes. */
7
+ export function normalizeMigrationResourcePath(
8
+ path: string,
9
+ context: string,
10
+ diagnostics: QtiMigrationDiagnostic[],
11
+ ): string | undefined {
12
+ const coreDiagnostics: QtiDiagnostic[] = [];
13
+ const normalized = normalizePackagePath(path.replaceAll("\\", "/"), context, coreDiagnostics);
14
+ diagnostics.push(
15
+ ...coreDiagnostics.map((entry) =>
16
+ diagnostic(
17
+ `resource_${entry.code}`,
18
+ entry.severity === "error" ? "error" : "warning",
19
+ entry.message,
20
+ {
21
+ path: entry.path,
22
+ },
23
+ ),
24
+ ),
25
+ );
26
+ if (normalized === "") {
27
+ diagnostics.push(
28
+ diagnostic("resource_path_empty", "error", `${context} must not be empty.`, { path }),
29
+ );
30
+ return undefined;
31
+ }
32
+ return normalized;
33
+ }