@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.
- package/README.md +54 -0
- package/dist/assets.d.ts +10 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/assets.js +19 -0
- package/dist/assets.js.map +1 -0
- package/dist/diagnostics.d.ts +2 -1
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +7 -0
- package/dist/diagnostics.js.map +1 -1
- package/dist/identifiers.d.ts +3 -0
- package/dist/identifiers.d.ts.map +1 -0
- package/dist/identifiers.js +11 -0
- package/dist/identifiers.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -133
- package/dist/index.js.map +1 -1
- package/dist/item-finalize.d.ts +12 -0
- package/dist/item-finalize.d.ts.map +1 -0
- package/dist/item-finalize.js +93 -0
- package/dist/item-finalize.js.map +1 -0
- package/dist/item-hrefs.d.ts +2 -0
- package/dist/item-hrefs.d.ts.map +1 -0
- package/dist/item-hrefs.js +19 -0
- package/dist/item-hrefs.js.map +1 -0
- package/dist/migrate-items.d.ts +6 -0
- package/dist/migrate-items.d.ts.map +1 -0
- package/dist/migrate-items.js +31 -0
- package/dist/migrate-items.js.map +1 -0
- package/dist/package-result.d.ts +12 -0
- package/dist/package-result.d.ts.map +1 -0
- package/dist/package-result.js +109 -0
- package/dist/package-result.js.map +1 -0
- package/dist/paths.d.ts +4 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +16 -0
- package/dist/paths.js.map +1 -0
- package/dist/resource-migration.d.ts +4 -0
- package/dist/resource-migration.d.ts.map +1 -0
- package/dist/resource-migration.js +128 -0
- package/dist/resource-migration.js.map +1 -0
- package/dist/source.d.ts +5 -0
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +26 -6
- package/dist/source.js.map +1 -1
- package/dist/types.d.ts +43 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/assets.ts +23 -0
- package/src/diagnostics.ts +12 -0
- package/src/identifiers.ts +12 -0
- package/src/index.ts +45 -176
- package/src/item-finalize.ts +133 -0
- package/src/item-hrefs.ts +23 -0
- package/src/migrate-items.ts +39 -0
- package/src/package-result.ts +157 -0
- package/src/paths.ts +33 -0
- package/src/resource-migration.ts +197 -0
- package/src/source.ts +40 -6
- package/src/types.ts +53 -1
package/src/index.ts
CHANGED
|
@@ -1,34 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 {
|
|
10
|
-
import {
|
|
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:
|
|
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 =
|
|
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(
|
|
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:
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
240
|
+
const href = source.filename ?? "item.xml";
|
|
241
|
+
const migratedItems = finalizeItemResults(
|
|
242
|
+
migrateItemXml(xml, href, sourceFormat, options),
|
|
243
|
+
href,
|
|
221
244
|
options,
|
|
222
|
-
)
|
|
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
|
+
}
|