@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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { decodeUtf8 } from "@longsightgroup/qti3-core";
|
|
2
|
+
|
|
3
|
+
import { migrationPackageAssets, resourceClosureAssetHrefs } from "./assets.js";
|
|
4
|
+
import { aggregateMigrationDiagnostics, diagnostic } from "./diagnostics.js";
|
|
5
|
+
import { finalizeItemResults } from "./item-finalize.js";
|
|
6
|
+
import { migrateItemXml } from "./migrate-items.js";
|
|
7
|
+
import { normalizeMigrationResourcePath } from "./paths.js";
|
|
8
|
+
import { migrationResultToPackageEntries } from "./package-result.js";
|
|
9
|
+
import { resolveOptions } from "./options.js";
|
|
10
|
+
import {
|
|
11
|
+
detectMigrationSource,
|
|
12
|
+
migrationEntriesFromFileMap,
|
|
13
|
+
type MigrationSource,
|
|
14
|
+
} from "./source.js";
|
|
15
|
+
import type {
|
|
16
|
+
QtiMigrationDiagnostic,
|
|
17
|
+
QtiMigrationOptions,
|
|
18
|
+
QtiMigrationPart,
|
|
19
|
+
QtiMigrationResourceInput,
|
|
20
|
+
QtiMigrationResult,
|
|
21
|
+
QtiMigrationSourceFormat,
|
|
22
|
+
QtiResourceMigrationResult,
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
|
|
25
|
+
interface NormalizedResourceInput {
|
|
26
|
+
readonly sourcePath: string;
|
|
27
|
+
readonly entries: ReturnType<typeof migrationEntriesFromFileMap>["entries"];
|
|
28
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Migrate one QTI resource and its local file closure into launchable QTI 3 package entries. */
|
|
32
|
+
export async function migrateQtiResourceToQti3(
|
|
33
|
+
input: QtiMigrationResourceInput,
|
|
34
|
+
options: QtiMigrationOptions = {},
|
|
35
|
+
): Promise<QtiResourceMigrationResult> {
|
|
36
|
+
const resolvedOptions = resolveOptions(options);
|
|
37
|
+
const normalized = normalizeResourceInput(input);
|
|
38
|
+
if (normalized.diagnostics.length) {
|
|
39
|
+
return failedResourceMigrationResult({
|
|
40
|
+
title: input.title ?? input.sourcePath,
|
|
41
|
+
diagnostics: normalized.diagnostics,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sourceEntry = normalized.entries.find((entry) => entry.path === normalized.sourcePath);
|
|
46
|
+
if (!sourceEntry) {
|
|
47
|
+
return failedResourceMigrationResult({
|
|
48
|
+
title: input.title ?? normalized.sourcePath,
|
|
49
|
+
diagnostics: [
|
|
50
|
+
diagnostic(
|
|
51
|
+
"resource_source_missing",
|
|
52
|
+
"error",
|
|
53
|
+
`QTI resource source path ${normalized.sourcePath} was not found in the resource files.`,
|
|
54
|
+
{ path: normalized.sourcePath },
|
|
55
|
+
),
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sourceXml = decodeUtf8(sourceEntry.bytes);
|
|
61
|
+
const source: MigrationSource = {
|
|
62
|
+
filename: normalized.sourcePath,
|
|
63
|
+
isPackage: false,
|
|
64
|
+
entries: [],
|
|
65
|
+
xml: sourceXml,
|
|
66
|
+
};
|
|
67
|
+
const detection = detectMigrationSource(source);
|
|
68
|
+
if (!detection.supported || !detection.sourceFormat) {
|
|
69
|
+
return failedResourceMigrationResult({
|
|
70
|
+
title: input.title ?? normalized.sourcePath,
|
|
71
|
+
sourceFormat: detection.sourceFormat,
|
|
72
|
+
diagnostics: [
|
|
73
|
+
diagnostic(
|
|
74
|
+
"source_unsupported",
|
|
75
|
+
"error",
|
|
76
|
+
`Unsupported QTI migration source: ${detection.reason}.`,
|
|
77
|
+
{ path: normalized.sourcePath, sourceFormat: detection.sourceFormat },
|
|
78
|
+
),
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const assetHrefs = resourceClosureAssetHrefs(normalized.entries);
|
|
84
|
+
const items = finalizeItemResults(
|
|
85
|
+
migrateItemXml(sourceXml, normalized.sourcePath, detection.sourceFormat, resolvedOptions),
|
|
86
|
+
normalized.sourcePath,
|
|
87
|
+
resolvedOptions,
|
|
88
|
+
).map((item) => ({ ...item, assetHrefs }));
|
|
89
|
+
|
|
90
|
+
const migration: QtiMigrationResult = {
|
|
91
|
+
title: input.title ?? items[0]?.title ?? normalized.sourcePath,
|
|
92
|
+
sourceFormat: detection.sourceFormat,
|
|
93
|
+
parts: [partFromItems("PART_1", input.title ?? "Imported Resource", items)],
|
|
94
|
+
items,
|
|
95
|
+
assets: migrationPackageAssets(normalized.entries),
|
|
96
|
+
diagnostics: [],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const packageEntries = migrationResultToPackageEntries(migration);
|
|
100
|
+
if (!packageEntries.ok) {
|
|
101
|
+
return failedResourceMigrationResult({
|
|
102
|
+
title: migration.title,
|
|
103
|
+
sourceFormat: detection.sourceFormat,
|
|
104
|
+
migration,
|
|
105
|
+
diagnostics: aggregateMigrationDiagnostics(migration, packageEntries.diagnostics),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const diagnostics = aggregateMigrationDiagnostics(migration, packageEntries.diagnostics);
|
|
110
|
+
const itemHrefs = items.map((item) => item.href);
|
|
111
|
+
const launchHref = itemHrefs[0];
|
|
112
|
+
if (!launchHref) {
|
|
113
|
+
return failedResourceMigrationResult({
|
|
114
|
+
title: migration.title,
|
|
115
|
+
sourceFormat: detection.sourceFormat,
|
|
116
|
+
migration,
|
|
117
|
+
diagnostics: [
|
|
118
|
+
...diagnostics,
|
|
119
|
+
diagnostic(
|
|
120
|
+
"resource_launch_href_missing",
|
|
121
|
+
"error",
|
|
122
|
+
"Migrated resource did not produce a launchable item path.",
|
|
123
|
+
{ path: normalized.sourcePath, sourceFormat: detection.sourceFormat },
|
|
124
|
+
),
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
title: migration.title,
|
|
132
|
+
status: diagnostics.some((entry) => entry.severity === "warning")
|
|
133
|
+
? "converted_with_warnings"
|
|
134
|
+
: "converted",
|
|
135
|
+
sourceFormat: detection.sourceFormat,
|
|
136
|
+
launchHref,
|
|
137
|
+
itemHrefs,
|
|
138
|
+
entries: packageEntries.entries,
|
|
139
|
+
diagnostics,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function normalizeResourceInput(input: QtiMigrationResourceInput): NormalizedResourceInput {
|
|
144
|
+
const diagnostics: QtiMigrationDiagnostic[] = [];
|
|
145
|
+
const sourcePath = normalizeMigrationResourcePath(
|
|
146
|
+
input.sourcePath,
|
|
147
|
+
"resource source path",
|
|
148
|
+
diagnostics,
|
|
149
|
+
);
|
|
150
|
+
const { entries, diagnostics: entryDiagnostics } = migrationEntriesFromFileMap(
|
|
151
|
+
input.files,
|
|
152
|
+
(path) => normalizeMigrationResourcePath(path, "resource file path", diagnostics),
|
|
153
|
+
);
|
|
154
|
+
return {
|
|
155
|
+
sourcePath: sourcePath ?? input.sourcePath.replaceAll("\\", "/"),
|
|
156
|
+
entries,
|
|
157
|
+
diagnostics: [...diagnostics, ...entryDiagnostics],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function partFromItems(
|
|
162
|
+
identifier: string,
|
|
163
|
+
title: string,
|
|
164
|
+
items: QtiMigrationResult["items"],
|
|
165
|
+
): QtiMigrationPart {
|
|
166
|
+
return { identifier, title, itemHrefs: items.map((item) => item.href) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function failedResourceMigrationResult(options: {
|
|
170
|
+
readonly title: string;
|
|
171
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
172
|
+
readonly sourceFormat?: QtiMigrationSourceFormat | undefined;
|
|
173
|
+
readonly migration?: QtiMigrationResult | undefined;
|
|
174
|
+
}): QtiResourceMigrationResult {
|
|
175
|
+
const migration =
|
|
176
|
+
options.migration ??
|
|
177
|
+
({
|
|
178
|
+
title: options.title,
|
|
179
|
+
sourceFormat: options.sourceFormat,
|
|
180
|
+
parts: [],
|
|
181
|
+
items: [],
|
|
182
|
+
assets: [],
|
|
183
|
+
diagnostics: options.diagnostics,
|
|
184
|
+
} satisfies QtiMigrationResult);
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
title: migration.title,
|
|
188
|
+
status: "failed",
|
|
189
|
+
sourceFormat: options.sourceFormat ?? migration.sourceFormat,
|
|
190
|
+
itemHrefs: migration.items.map((item) => item.href),
|
|
191
|
+
entries: [],
|
|
192
|
+
diagnostics: options.diagnostics.length
|
|
193
|
+
? options.diagnostics
|
|
194
|
+
: aggregateMigrationDiagnostics(migration),
|
|
195
|
+
migration,
|
|
196
|
+
};
|
|
197
|
+
}
|
package/src/source.ts
CHANGED
|
@@ -21,6 +21,44 @@ export interface MigrationSource {
|
|
|
21
21
|
readonly xml?: string | undefined;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export function buildMigrationEntry(path: string, bytes: Uint8Array): MigrationEntry {
|
|
25
|
+
return {
|
|
26
|
+
path,
|
|
27
|
+
bytes,
|
|
28
|
+
text: isMigrationTextPath(path) ? decodeUtf8(bytes) : undefined,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function migrationEntriesFromFileMap(
|
|
33
|
+
files: Readonly<Record<string, Uint8Array>>,
|
|
34
|
+
normalizePath: (path: string) => string | undefined,
|
|
35
|
+
): {
|
|
36
|
+
readonly entries: readonly MigrationEntry[];
|
|
37
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
38
|
+
} {
|
|
39
|
+
const diagnostics: QtiMigrationDiagnostic[] = [];
|
|
40
|
+
const entries: MigrationEntry[] = [];
|
|
41
|
+
const seenPaths = new Set<string>();
|
|
42
|
+
for (const [path, bytes] of Object.entries(files)) {
|
|
43
|
+
const normalizedPath = normalizePath(path);
|
|
44
|
+
if (!normalizedPath) continue;
|
|
45
|
+
if (seenPaths.has(normalizedPath)) {
|
|
46
|
+
diagnostics.push(
|
|
47
|
+
diagnostic(
|
|
48
|
+
"resource_file_duplicate",
|
|
49
|
+
"error",
|
|
50
|
+
`Resource file path ${normalizedPath} is duplicated after normalization.`,
|
|
51
|
+
{ path: normalizedPath },
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
seenPaths.add(normalizedPath);
|
|
57
|
+
entries.push(buildMigrationEntry(normalizedPath, bytes));
|
|
58
|
+
}
|
|
59
|
+
return { entries, diagnostics };
|
|
60
|
+
}
|
|
61
|
+
|
|
24
62
|
export function readMigrationSource(input: QtiMigrationSourceInput): MigrationSource {
|
|
25
63
|
if (input.xml !== undefined) {
|
|
26
64
|
return { filename: input.filename, isPackage: false, entries: [], xml: input.xml };
|
|
@@ -32,11 +70,7 @@ export function readMigrationSource(input: QtiMigrationSourceInput): MigrationSo
|
|
|
32
70
|
const unzipped = unzipSync(input.bytes);
|
|
33
71
|
const entries = Object.entries(unzipped)
|
|
34
72
|
.filter(([path]) => !path.endsWith("/"))
|
|
35
|
-
.map(([path, bytes]) => (
|
|
36
|
-
path: path.replaceAll("\\", "/"),
|
|
37
|
-
bytes,
|
|
38
|
-
text: isTextPath(path) ? decodeUtf8(bytes) : undefined,
|
|
39
|
-
}));
|
|
73
|
+
.map(([path, bytes]) => buildMigrationEntry(path.replaceAll("\\", "/"), bytes));
|
|
40
74
|
return { filename: input.filename, isPackage: true, entries };
|
|
41
75
|
}
|
|
42
76
|
return {
|
|
@@ -246,6 +280,6 @@ function isZip(bytes: Uint8Array): boolean {
|
|
|
246
280
|
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b;
|
|
247
281
|
}
|
|
248
282
|
|
|
249
|
-
function
|
|
283
|
+
function isMigrationTextPath(path: string): boolean {
|
|
250
284
|
return /\.(xml|html?|txt|json|css|js)$/i.test(path);
|
|
251
285
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Qti3AuthoringItem,
|
|
3
|
+
Qti3PackageAuthoringInput,
|
|
4
|
+
Qti3WriterDiagnostic,
|
|
5
|
+
} from "@longsightgroup/qti3-writer";
|
|
2
6
|
|
|
3
7
|
export type QtiMigrationSourceFormat = "qti12" | "qti21" | "qti22";
|
|
4
8
|
|
|
@@ -13,6 +17,13 @@ export interface QtiMigrationSourceInput {
|
|
|
13
17
|
readonly mime?: string | undefined;
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
/** Local file closure for migrating one QTI resource from a larger host package. */
|
|
21
|
+
export interface QtiMigrationResourceInput {
|
|
22
|
+
readonly sourcePath: string;
|
|
23
|
+
readonly files: Readonly<Record<string, Uint8Array>>;
|
|
24
|
+
readonly title?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
export interface QtiMigrationOptions {
|
|
17
28
|
readonly repairPolicy?: QtiMigrationRepairPolicy | undefined;
|
|
18
29
|
readonly unsupportedPolicy?: QtiMigrationUnsupportedPolicy | undefined;
|
|
@@ -58,6 +69,7 @@ export interface QtiMigrationItemResult {
|
|
|
58
69
|
readonly identifier: string;
|
|
59
70
|
readonly title: string;
|
|
60
71
|
readonly href: string;
|
|
72
|
+
readonly assetHrefs?: readonly string[] | undefined;
|
|
61
73
|
readonly authoringItem?: Qti3AuthoringItem | undefined;
|
|
62
74
|
readonly xml?: string | undefined;
|
|
63
75
|
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
@@ -71,3 +83,43 @@ export interface QtiMigrationResult {
|
|
|
71
83
|
readonly assets: readonly QtiMigrationAsset[];
|
|
72
84
|
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
73
85
|
}
|
|
86
|
+
|
|
87
|
+
/** File emitted by resource-level migration for host storage or packaging. */
|
|
88
|
+
export interface QtiMigrationResourceEntry {
|
|
89
|
+
readonly path: string;
|
|
90
|
+
readonly data: Uint8Array | string;
|
|
91
|
+
readonly mediaType?: string | undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type QtiResourceMigrationStatus = "converted" | "converted_with_warnings" | "failed";
|
|
95
|
+
|
|
96
|
+
export type QtiPackageMigrationResult =
|
|
97
|
+
| {
|
|
98
|
+
readonly ok: true;
|
|
99
|
+
readonly package: Qti3PackageAuthoringInput;
|
|
100
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
101
|
+
}
|
|
102
|
+
| { readonly ok: false; readonly diagnostics: readonly QtiMigrationDiagnostic[] };
|
|
103
|
+
|
|
104
|
+
/** Result of migrating one host-package resource and its local file closure. */
|
|
105
|
+
export type QtiResourceMigrationResult =
|
|
106
|
+
| {
|
|
107
|
+
readonly ok: true;
|
|
108
|
+
readonly title: string;
|
|
109
|
+
readonly status: "converted" | "converted_with_warnings";
|
|
110
|
+
readonly sourceFormat: QtiMigrationSourceFormat;
|
|
111
|
+
readonly launchHref: string;
|
|
112
|
+
readonly itemHrefs: readonly string[];
|
|
113
|
+
readonly entries: readonly QtiMigrationResourceEntry[];
|
|
114
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
115
|
+
}
|
|
116
|
+
| {
|
|
117
|
+
readonly ok: false;
|
|
118
|
+
readonly title: string;
|
|
119
|
+
readonly status: "failed";
|
|
120
|
+
readonly sourceFormat?: QtiMigrationSourceFormat | undefined;
|
|
121
|
+
readonly itemHrefs: readonly string[];
|
|
122
|
+
readonly entries: readonly QtiMigrationResourceEntry[];
|
|
123
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
124
|
+
readonly migration: QtiMigrationResult;
|
|
125
|
+
};
|