@longsightgroup/qti3-core 0.9.3 → 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.
- package/README.md +88 -8
- package/dist/adaptive-turn-materializer.d.ts +2 -0
- package/dist/adaptive-turn-materializer.d.ts.map +1 -1
- package/dist/adaptive-turn-materializer.js +25 -19
- package/dist/adaptive-turn-materializer.js.map +1 -1
- package/dist/adaptive-turn.d.ts +8 -1
- package/dist/adaptive-turn.d.ts.map +1 -1
- package/dist/adaptive-turn.js +15 -5
- package/dist/adaptive-turn.js.map +1 -1
- package/dist/delivery-preparation.d.ts +20 -0
- package/dist/delivery-preparation.d.ts.map +1 -0
- package/dist/delivery-preparation.js +71 -0
- package/dist/delivery-preparation.js.map +1 -0
- package/dist/delivery-redaction.d.ts +5 -0
- package/dist/delivery-redaction.d.ts.map +1 -1
- package/dist/delivery-redaction.js +26 -5
- package/dist/delivery-redaction.js.map +1 -1
- package/dist/delivery-security.js +2 -2
- package/dist/delivery-security.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/item-scoring-expectation.d.ts +4 -0
- package/dist/item-scoring-expectation.d.ts.map +1 -0
- package/dist/item-scoring-expectation.js +6 -0
- package/dist/item-scoring-expectation.js.map +1 -0
- package/dist/item-submission-materialization.d.ts +25 -0
- package/dist/item-submission-materialization.d.ts.map +1 -0
- package/dist/item-submission-materialization.js +68 -0
- package/dist/item-submission-materialization.js.map +1 -0
- package/dist/qti-package-assets.d.ts +8 -0
- package/dist/qti-package-assets.d.ts.map +1 -0
- package/dist/qti-package-assets.js +173 -0
- package/dist/qti-package-assets.js.map +1 -0
- package/dist/qti-package-items.d.ts +14 -0
- package/dist/qti-package-items.d.ts.map +1 -0
- package/dist/qti-package-items.js +138 -0
- package/dist/qti-package-items.js.map +1 -0
- package/dist/qti-package-manifest.d.ts +13 -0
- package/dist/qti-package-manifest.d.ts.map +1 -0
- package/dist/qti-package-manifest.js +109 -0
- package/dist/qti-package-manifest.js.map +1 -0
- package/dist/qti-package-metadata.d.ts +8 -0
- package/dist/qti-package-metadata.d.ts.map +1 -0
- package/dist/qti-package-metadata.js +115 -0
- package/dist/qti-package-metadata.js.map +1 -0
- package/dist/qti-package-paths.d.ts +6 -0
- package/dist/qti-package-paths.d.ts.map +1 -0
- package/dist/qti-package-paths.js +32 -0
- package/dist/qti-package-paths.js.map +1 -0
- package/dist/qti-package-types.d.ts +98 -0
- package/dist/qti-package-types.d.ts.map +1 -0
- package/dist/qti-package-types.js +2 -0
- package/dist/qti-package-types.js.map +1 -0
- package/dist/qti-package-xml.d.ts +21 -0
- package/dist/qti-package-xml.d.ts.map +1 -0
- package/dist/qti-package-xml.js +68 -0
- package/dist/qti-package-xml.js.map +1 -0
- package/dist/qti-package-zip.d.ts +24 -0
- package/dist/qti-package-zip.d.ts.map +1 -0
- package/dist/qti-package-zip.js +185 -0
- package/dist/qti-package-zip.js.map +1 -0
- package/dist/qti-package.d.ts +7 -0
- package/dist/qti-package.d.ts.map +1 -0
- package/dist/qti-package.js +76 -0
- package/dist/qti-package.js.map +1 -0
- package/dist/response-input.d.ts +9 -0
- package/dist/response-input.d.ts.map +1 -0
- package/dist/response-input.js +9 -0
- package/dist/response-input.js.map +1 -0
- package/dist/response-validation-policy.d.ts +31 -0
- package/dist/response-validation-policy.d.ts.map +1 -0
- package/dist/response-validation-policy.js +166 -0
- package/dist/response-validation-policy.js.map +1 -0
- package/dist/response-validation.d.ts +23 -0
- package/dist/response-validation.d.ts.map +1 -0
- package/dist/response-validation.js +170 -0
- package/dist/response-validation.js.map +1 -0
- package/dist/scoring-disposition-policy.d.ts +8 -0
- package/dist/scoring-disposition-policy.d.ts.map +1 -0
- package/dist/scoring-disposition-policy.js +24 -0
- package/dist/scoring-disposition-policy.js.map +1 -0
- package/dist/template-presentation-materializer.d.ts +14 -0
- package/dist/template-presentation-materializer.d.ts.map +1 -0
- package/dist/template-presentation-materializer.js +143 -0
- package/dist/template-presentation-materializer.js.map +1 -0
- package/dist/trusted-item-session.d.ts +25 -8
- package/dist/trusted-item-session.d.ts.map +1 -1
- package/dist/trusted-item-session.js +51 -46
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/trusted-session-materialization.d.ts +33 -0
- package/dist/trusted-session-materialization.d.ts.map +1 -0
- package/dist/trusted-session-materialization.js +26 -0
- package/dist/trusted-session-materialization.js.map +1 -0
- package/dist/validation-interactions.js +1 -0
- package/dist/validation-interactions.js.map +1 -1
- package/dist/validation-primitives.d.ts +1 -0
- package/dist/validation-primitives.d.ts.map +1 -1
- package/dist/validation-primitives.js +5 -0
- package/dist/validation-primitives.js.map +1 -1
- package/dist/variable-snapshot.d.ts +9 -0
- package/dist/variable-snapshot.d.ts.map +1 -0
- package/dist/variable-snapshot.js +9 -0
- package/dist/variable-snapshot.js.map +1 -0
- package/package.json +1 -1
- package/src/adaptive-turn-materializer.ts +29 -16
- package/src/adaptive-turn.ts +29 -6
- package/src/delivery-preparation.ts +121 -0
- package/src/delivery-redaction.ts +36 -5
- package/src/delivery-security.ts +2 -2
- package/src/index.ts +65 -0
- package/src/item-scoring-expectation.ts +9 -0
- package/src/item-submission-materialization.ts +120 -0
- package/src/qti-package-assets.ts +256 -0
- package/src/qti-package-items.ts +240 -0
- package/src/qti-package-manifest.ts +210 -0
- package/src/qti-package-metadata.ts +143 -0
- package/src/qti-package-paths.ts +56 -0
- package/src/qti-package-types.ts +117 -0
- package/src/qti-package-xml.ts +97 -0
- package/src/qti-package-zip.ts +325 -0
- package/src/qti-package.ts +176 -0
- package/src/response-input.ts +19 -0
- package/src/response-validation-policy.ts +226 -0
- package/src/response-validation.ts +298 -0
- package/src/scoring-disposition-policy.ts +37 -0
- package/src/template-presentation-materializer.ts +219 -0
- package/src/trusted-item-session.ts +80 -74
- package/src/trusted-session-materialization.ts +67 -0
- package/src/validation-interactions.ts +1 -0
- package/src/validation-primitives.ts +5 -0
- package/src/variable-snapshot.ts +20 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { QtiPackageXmlNode } from "./package-xml.js";
|
|
2
|
+
import { parseQtiXml } from "./parser.js";
|
|
3
|
+
import { pushPackageDiagnostic } from "./qti-package-paths.js";
|
|
4
|
+
import { discoverContentAssetHrefs } from "./qti-package-assets.js";
|
|
5
|
+
import { QTI_PACKAGE_MANIFEST_PATH } from "./qti-package-manifest.js";
|
|
6
|
+
import { parseStandardAlignments, parseTimingMetadata } from "./qti-package-metadata.js";
|
|
7
|
+
import type {
|
|
8
|
+
QtiAssessmentTestItemRef,
|
|
9
|
+
QtiAssessmentTestPackageModel,
|
|
10
|
+
QtiManifestResource,
|
|
11
|
+
QtiPackageItem,
|
|
12
|
+
QtiPackageItemSource,
|
|
13
|
+
QtiPackageShape,
|
|
14
|
+
} from "./qti-package-types.js";
|
|
15
|
+
import {
|
|
16
|
+
packageDescendants,
|
|
17
|
+
parsePackageXml,
|
|
18
|
+
pushXmlDiagnostics,
|
|
19
|
+
resolvePackageHref,
|
|
20
|
+
scopeDiagnosticToPackagePath,
|
|
21
|
+
type PackageXmlFile,
|
|
22
|
+
} from "./qti-package-xml.js";
|
|
23
|
+
import type { QtiPackageEntry } from "./qti-package-zip.js";
|
|
24
|
+
import type { QtiDiagnostic } from "./types.js";
|
|
25
|
+
|
|
26
|
+
export interface PackageItemReference {
|
|
27
|
+
readonly href: string;
|
|
28
|
+
readonly source: QtiPackageItemSource;
|
|
29
|
+
readonly manifestResourceIdentifier?: string | undefined;
|
|
30
|
+
readonly assessmentItemRefIdentifier?: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseAssessmentTestPackageModel(
|
|
34
|
+
packageShape: QtiPackageShape,
|
|
35
|
+
assessmentTestResources: readonly QtiManifestResource[],
|
|
36
|
+
entriesByPath: ReadonlyMap<string, QtiPackageEntry>,
|
|
37
|
+
xmlFilesByPath: ReadonlyMap<string, PackageXmlFile>,
|
|
38
|
+
diagnostics: QtiDiagnostic[],
|
|
39
|
+
): QtiAssessmentTestPackageModel | undefined {
|
|
40
|
+
if (packageShape !== "assessment-test-resource") return undefined;
|
|
41
|
+
|
|
42
|
+
const firstResource = assessmentTestResources[0];
|
|
43
|
+
if (!firstResource) return undefined;
|
|
44
|
+
|
|
45
|
+
if (assessmentTestResources.length > 1) {
|
|
46
|
+
pushPackageDiagnostic(
|
|
47
|
+
diagnostics,
|
|
48
|
+
"package.assessmentTest.resource.ambiguous",
|
|
49
|
+
"error",
|
|
50
|
+
"Manifest contains multiple QTI assessment-test resources.",
|
|
51
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const href = firstResource.href;
|
|
56
|
+
if (!href) return undefined;
|
|
57
|
+
if (!entriesByPath.has(href)) {
|
|
58
|
+
pushPackageDiagnostic(
|
|
59
|
+
diagnostics,
|
|
60
|
+
"package.assessmentTest.missing",
|
|
61
|
+
"error",
|
|
62
|
+
`Assessment test resource ${href} was not found in the package.`,
|
|
63
|
+
href,
|
|
64
|
+
);
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const entry = entriesByPath.get(href);
|
|
69
|
+
if (!entry) return undefined;
|
|
70
|
+
const xmlFile = xmlFilesByPath.get(href) ?? parsePackageXml(entry);
|
|
71
|
+
const testDiagnostics: QtiDiagnostic[] = [];
|
|
72
|
+
pushXmlDiagnostics(xmlFile, testDiagnostics);
|
|
73
|
+
const root = xmlFile.root?.localName === "qti-assessment-test" ? xmlFile.root : undefined;
|
|
74
|
+
if (xmlFile.root && !root) {
|
|
75
|
+
pushPackageDiagnostic(
|
|
76
|
+
testDiagnostics,
|
|
77
|
+
"package.assessmentTest.root",
|
|
78
|
+
"error",
|
|
79
|
+
`Expected qti-assessment-test root, found ${xmlFile.root.localName}.`,
|
|
80
|
+
href,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const itemRefs = root ? parseAssessmentTestItemRefs(root, href, testDiagnostics) : [];
|
|
85
|
+
const assetHrefs = root ? discoverContentAssetHrefs(xmlFile, testDiagnostics) : [];
|
|
86
|
+
const scopedDiagnostics = testDiagnostics.map((diagnostic) =>
|
|
87
|
+
scopeDiagnosticToPackagePath(href, diagnostic),
|
|
88
|
+
);
|
|
89
|
+
diagnostics.push(...scopedDiagnostics);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
href,
|
|
93
|
+
identifier: root?.attributes.identifier ?? "",
|
|
94
|
+
title: root?.attributes.title,
|
|
95
|
+
manifestResourceIdentifier: firstResource.identifier,
|
|
96
|
+
itemRefs,
|
|
97
|
+
timing: root ? parseTimingMetadata(root, href) : undefined,
|
|
98
|
+
standards: root ? parseStandardAlignments(root, href) : [],
|
|
99
|
+
assetHrefs,
|
|
100
|
+
diagnostics: scopedDiagnostics,
|
|
101
|
+
attributes: root ? { ...root.attributes } : {},
|
|
102
|
+
xml: xmlFile.xml,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseAssessmentTestItemRefs(
|
|
107
|
+
root: QtiPackageXmlNode,
|
|
108
|
+
sourcePath: string,
|
|
109
|
+
diagnostics: QtiDiagnostic[],
|
|
110
|
+
): QtiAssessmentTestItemRef[] {
|
|
111
|
+
const itemRefs: QtiAssessmentTestItemRef[] = [];
|
|
112
|
+
for (const refNode of packageDescendants(root, "qti-assessment-item-ref")) {
|
|
113
|
+
const rawHref = refNode.attributes.href;
|
|
114
|
+
if (!rawHref) {
|
|
115
|
+
pushPackageDiagnostic(
|
|
116
|
+
diagnostics,
|
|
117
|
+
"package.assessmentTest.itemRef.href.missing",
|
|
118
|
+
"error",
|
|
119
|
+
"qti-assessment-item-ref is missing href.",
|
|
120
|
+
sourcePath,
|
|
121
|
+
);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const href = resolvePackageHref(sourcePath, rawHref, diagnostics);
|
|
126
|
+
if (!href) continue;
|
|
127
|
+
itemRefs.push({
|
|
128
|
+
identifier: refNode.attributes.identifier,
|
|
129
|
+
href,
|
|
130
|
+
attributes: { ...refNode.attributes },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return itemRefs;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function itemReferencesForPackageShape(
|
|
137
|
+
packageShape: QtiPackageShape,
|
|
138
|
+
itemResources: readonly QtiManifestResource[],
|
|
139
|
+
assessmentTest: QtiAssessmentTestPackageModel | undefined,
|
|
140
|
+
diagnostics: QtiDiagnostic[],
|
|
141
|
+
): PackageItemReference[] {
|
|
142
|
+
if (packageShape === "assessment-test-resource") {
|
|
143
|
+
return (assessmentTest?.itemRefs ?? []).map((itemRef) => ({
|
|
144
|
+
href: itemRef.href,
|
|
145
|
+
source: "assessment-test",
|
|
146
|
+
assessmentItemRefIdentifier: itemRef.identifier,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (packageShape !== "manifest-item-resources") return [];
|
|
151
|
+
|
|
152
|
+
const references: PackageItemReference[] = [];
|
|
153
|
+
const seen = new Set<string>();
|
|
154
|
+
for (const resource of itemResources) {
|
|
155
|
+
const href = resource.href;
|
|
156
|
+
if (!href) continue;
|
|
157
|
+
if (seen.has(href)) {
|
|
158
|
+
pushPackageDiagnostic(
|
|
159
|
+
diagnostics,
|
|
160
|
+
"package.manifest.itemResource.href.duplicate",
|
|
161
|
+
"error",
|
|
162
|
+
`Multiple manifest item resources point to ${href}.`,
|
|
163
|
+
href,
|
|
164
|
+
);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
seen.add(href);
|
|
168
|
+
references.push({
|
|
169
|
+
href,
|
|
170
|
+
source: "manifest",
|
|
171
|
+
manifestResourceIdentifier: resource.identifier,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return references;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parsePackageItems(
|
|
178
|
+
references: readonly PackageItemReference[],
|
|
179
|
+
entriesByPath: ReadonlyMap<string, QtiPackageEntry>,
|
|
180
|
+
xmlFilesByPath: ReadonlyMap<string, PackageXmlFile>,
|
|
181
|
+
diagnostics: QtiDiagnostic[],
|
|
182
|
+
): QtiPackageItem[] {
|
|
183
|
+
const items: QtiPackageItem[] = [];
|
|
184
|
+
const seen = new Set<string>();
|
|
185
|
+
|
|
186
|
+
for (const reference of references) {
|
|
187
|
+
if (seen.has(reference.href)) {
|
|
188
|
+
pushPackageDiagnostic(
|
|
189
|
+
diagnostics,
|
|
190
|
+
"package.item.reference.duplicate",
|
|
191
|
+
"warning",
|
|
192
|
+
`Package item ${reference.href} is referenced more than once.`,
|
|
193
|
+
reference.href,
|
|
194
|
+
);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
seen.add(reference.href);
|
|
198
|
+
|
|
199
|
+
const entry = entriesByPath.get(reference.href);
|
|
200
|
+
if (!entry) {
|
|
201
|
+
pushPackageDiagnostic(
|
|
202
|
+
diagnostics,
|
|
203
|
+
"package.item.missing",
|
|
204
|
+
"error",
|
|
205
|
+
`Package item reference ${reference.href} was not found.`,
|
|
206
|
+
reference.href,
|
|
207
|
+
);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const xmlFile = xmlFilesByPath.get(reference.href) ?? parsePackageXml(entry);
|
|
212
|
+
// Full QTI parse for document model; lightweight tree above supplies metadata and asset refs.
|
|
213
|
+
const parsed = parseQtiXml(xmlFile.xml);
|
|
214
|
+
const itemDiagnostics = parsed.diagnostics.map((diagnostic) =>
|
|
215
|
+
scopeDiagnosticToPackagePath(reference.href, diagnostic),
|
|
216
|
+
);
|
|
217
|
+
diagnostics.push(...itemDiagnostics);
|
|
218
|
+
|
|
219
|
+
const assetHrefs = xmlFile.root ? discoverContentAssetHrefs(xmlFile, diagnostics) : [];
|
|
220
|
+
const root = xmlFile.root?.localName === "qti-assessment-item" ? xmlFile.root : undefined;
|
|
221
|
+
const item = parsed.document?.item;
|
|
222
|
+
|
|
223
|
+
items.push({
|
|
224
|
+
href: reference.href,
|
|
225
|
+
source: reference.source,
|
|
226
|
+
manifestResourceIdentifier: reference.manifestResourceIdentifier,
|
|
227
|
+
assessmentItemRefIdentifier: reference.assessmentItemRefIdentifier,
|
|
228
|
+
identifier: item?.identifier,
|
|
229
|
+
title: item?.title,
|
|
230
|
+
document: parsed.document,
|
|
231
|
+
timing: root ? parseTimingMetadata(root, reference.href) : undefined,
|
|
232
|
+
standards: root ? parseStandardAlignments(root, reference.href) : [],
|
|
233
|
+
assetHrefs,
|
|
234
|
+
diagnostics: itemDiagnostics,
|
|
235
|
+
xml: xmlFile.xml,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return items;
|
|
240
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { QtiPackageXmlNode } from "./package-xml.js";
|
|
2
|
+
import { normalizePackagePath, pushPackageDiagnostic } from "./qti-package-paths.js";
|
|
3
|
+
import type { QtiManifestFile, QtiManifestResource, QtiPackageShape } from "./qti-package-types.js";
|
|
4
|
+
import { childPackageElements, packageDescendants, stripHrefSuffix } from "./qti-package-xml.js";
|
|
5
|
+
import type { QtiPackageEntry } from "./qti-package-zip.js";
|
|
6
|
+
import type { QtiDiagnostic } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export const QTI_PACKAGE_MANIFEST_PATH = "imsmanifest.xml";
|
|
9
|
+
|
|
10
|
+
export function isQtiItemResource(type: string): boolean {
|
|
11
|
+
return type.toLowerCase().startsWith("imsqti_item_xmlv3p0");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isQtiAssessmentTestResource(type: string): boolean {
|
|
15
|
+
return type.toLowerCase().startsWith("imsqti_test_xmlv3p0");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseManifestResources(
|
|
19
|
+
manifestRoot: QtiPackageXmlNode,
|
|
20
|
+
entriesByPath: ReadonlyMap<string, QtiPackageEntry>,
|
|
21
|
+
diagnostics: QtiDiagnostic[],
|
|
22
|
+
): QtiManifestResource[] {
|
|
23
|
+
const resourceNodes = packageDescendants(manifestRoot, "resources").flatMap((resources) =>
|
|
24
|
+
childPackageElements(resources, "resource"),
|
|
25
|
+
);
|
|
26
|
+
const resources: QtiManifestResource[] = [];
|
|
27
|
+
|
|
28
|
+
for (const node of resourceNodes) {
|
|
29
|
+
const identifier = node.attributes.identifier ?? "";
|
|
30
|
+
if (identifier.length === 0) {
|
|
31
|
+
pushPackageDiagnostic(
|
|
32
|
+
diagnostics,
|
|
33
|
+
"package.manifest.resource.identifier.missing",
|
|
34
|
+
"error",
|
|
35
|
+
"Manifest resource is missing an identifier.",
|
|
36
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const files = parseManifestFiles(node, entriesByPath, diagnostics);
|
|
41
|
+
const href =
|
|
42
|
+
node.attributes.href === undefined
|
|
43
|
+
? firstXmlManifestFileHref(files)
|
|
44
|
+
: normalizePackagePath(
|
|
45
|
+
stripHrefSuffix(node.attributes.href),
|
|
46
|
+
`manifest resource ${identifier || "(missing identifier)"} href`,
|
|
47
|
+
diagnostics,
|
|
48
|
+
);
|
|
49
|
+
const dependencies = parseManifestDependencies(node, diagnostics);
|
|
50
|
+
|
|
51
|
+
resources.push({
|
|
52
|
+
identifier,
|
|
53
|
+
type: node.attributes.type ?? "",
|
|
54
|
+
href,
|
|
55
|
+
files,
|
|
56
|
+
dependencies,
|
|
57
|
+
attributes: { ...node.attributes },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return resources;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseManifestFiles(
|
|
65
|
+
resourceNode: QtiPackageXmlNode,
|
|
66
|
+
entriesByPath: ReadonlyMap<string, QtiPackageEntry>,
|
|
67
|
+
diagnostics: QtiDiagnostic[],
|
|
68
|
+
): QtiManifestFile[] {
|
|
69
|
+
const files: QtiManifestFile[] = [];
|
|
70
|
+
for (const fileNode of packageDescendants(resourceNode, "file")) {
|
|
71
|
+
const rawHref = fileNode.attributes.href;
|
|
72
|
+
if (!rawHref) {
|
|
73
|
+
pushPackageDiagnostic(
|
|
74
|
+
diagnostics,
|
|
75
|
+
"package.manifest.file.href.missing",
|
|
76
|
+
"error",
|
|
77
|
+
"Manifest file entry is missing href.",
|
|
78
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
79
|
+
);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const href = normalizePackagePath(stripHrefSuffix(rawHref), "manifest file href", diagnostics);
|
|
84
|
+
if (!href) continue;
|
|
85
|
+
files.push({ href, attributes: { ...fileNode.attributes } });
|
|
86
|
+
if (!entriesByPath.has(href)) {
|
|
87
|
+
pushPackageDiagnostic(
|
|
88
|
+
diagnostics,
|
|
89
|
+
"package.manifest.file.missing",
|
|
90
|
+
"error",
|
|
91
|
+
`Manifest file reference ${href} was not found in the package.`,
|
|
92
|
+
href,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return files;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseManifestDependencies(
|
|
100
|
+
resourceNode: QtiPackageXmlNode,
|
|
101
|
+
diagnostics: QtiDiagnostic[],
|
|
102
|
+
): string[] {
|
|
103
|
+
const dependencies: string[] = [];
|
|
104
|
+
for (const dependencyNode of packageDescendants(resourceNode, "dependency")) {
|
|
105
|
+
const identifierref = dependencyNode.attributes.identifierref;
|
|
106
|
+
if (!identifierref) {
|
|
107
|
+
pushPackageDiagnostic(
|
|
108
|
+
diagnostics,
|
|
109
|
+
"package.manifest.dependency.identifierref.missing",
|
|
110
|
+
"error",
|
|
111
|
+
"Manifest dependency is missing identifierref.",
|
|
112
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
113
|
+
);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
dependencies.push(identifierref);
|
|
117
|
+
}
|
|
118
|
+
return [...new Set(dependencies)];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function firstXmlManifestFileHref(files: readonly QtiManifestFile[]): string | undefined {
|
|
122
|
+
return files.find((file) => file.href.toLowerCase().endsWith(".xml"))?.href;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function indexManifestResources(
|
|
126
|
+
resources: readonly QtiManifestResource[],
|
|
127
|
+
diagnostics: QtiDiagnostic[],
|
|
128
|
+
): Map<string, QtiManifestResource> {
|
|
129
|
+
const byIdentifier = new Map<string, QtiManifestResource>();
|
|
130
|
+
for (const resource of resources) {
|
|
131
|
+
const identifier = resource.identifier;
|
|
132
|
+
if (identifier.length === 0) continue;
|
|
133
|
+
if (byIdentifier.has(identifier)) {
|
|
134
|
+
pushPackageDiagnostic(
|
|
135
|
+
diagnostics,
|
|
136
|
+
"package.manifest.resource.identifier.duplicate",
|
|
137
|
+
"error",
|
|
138
|
+
`Manifest contains duplicate resource identifier ${identifier}.`,
|
|
139
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
140
|
+
);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
byIdentifier.set(identifier, resource);
|
|
144
|
+
}
|
|
145
|
+
return byIdentifier;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function diagnoseManifestDependencyReferences(
|
|
149
|
+
resources: readonly QtiManifestResource[],
|
|
150
|
+
resourcesByIdentifier: ReadonlyMap<string, QtiManifestResource>,
|
|
151
|
+
diagnostics: QtiDiagnostic[],
|
|
152
|
+
): void {
|
|
153
|
+
for (const resource of resources) {
|
|
154
|
+
for (const dependency of resource.dependencies) {
|
|
155
|
+
if (resourcesByIdentifier.has(dependency)) continue;
|
|
156
|
+
pushPackageDiagnostic(
|
|
157
|
+
diagnostics,
|
|
158
|
+
"package.manifest.dependency.missing",
|
|
159
|
+
"error",
|
|
160
|
+
`Manifest resource ${resource.identifier} depends on missing resource ${dependency}.`,
|
|
161
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function diagnosePrimaryResourceHrefs(
|
|
168
|
+
resources: readonly QtiManifestResource[],
|
|
169
|
+
diagnostics: QtiDiagnostic[],
|
|
170
|
+
): void {
|
|
171
|
+
for (const resource of resources) {
|
|
172
|
+
if (resource.href) continue;
|
|
173
|
+
pushPackageDiagnostic(
|
|
174
|
+
diagnostics,
|
|
175
|
+
"package.manifest.resource.href.missing",
|
|
176
|
+
"error",
|
|
177
|
+
`Manifest resource ${resource.identifier || "(missing identifier)"} is missing a primary XML href.`,
|
|
178
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function detectPackageShape(
|
|
184
|
+
itemResources: readonly QtiManifestResource[],
|
|
185
|
+
assessmentTestResources: readonly QtiManifestResource[],
|
|
186
|
+
diagnostics: QtiDiagnostic[],
|
|
187
|
+
): QtiPackageShape {
|
|
188
|
+
if (assessmentTestResources.length > 0 && itemResources.length > 0) {
|
|
189
|
+
pushPackageDiagnostic(
|
|
190
|
+
diagnostics,
|
|
191
|
+
"package.shape.ambiguous",
|
|
192
|
+
"error",
|
|
193
|
+
"Manifest contains both QTI item resources and QTI assessment-test resources.",
|
|
194
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
195
|
+
);
|
|
196
|
+
return "unknown";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (assessmentTestResources.length > 0) return "assessment-test-resource";
|
|
200
|
+
if (itemResources.length > 0) return "manifest-item-resources";
|
|
201
|
+
|
|
202
|
+
pushPackageDiagnostic(
|
|
203
|
+
diagnostics,
|
|
204
|
+
"package.shape.unsupported",
|
|
205
|
+
"error",
|
|
206
|
+
"Manifest does not contain QTI item or assessment-test resources.",
|
|
207
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
208
|
+
);
|
|
209
|
+
return "unknown";
|
|
210
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { QtiPackageXmlNode } from "./package-xml.js";
|
|
2
|
+
import { parseXmlBoolean } from "./parser-values.js";
|
|
3
|
+
import { packageDescendants } from "./qti-package-xml.js";
|
|
4
|
+
import type {
|
|
5
|
+
QtiAssessmentTestPackageModel,
|
|
6
|
+
QtiPackageItem,
|
|
7
|
+
QtiStandardAlignment,
|
|
8
|
+
QtiTimingMetadata,
|
|
9
|
+
} from "./qti-package-types.js";
|
|
10
|
+
|
|
11
|
+
/** Known QTI / IMS CP alignment element names plus attribute-driven manifest metadata. */
|
|
12
|
+
const STANDARD_ALIGNMENT_ELEMENT_NAMES = new Set([
|
|
13
|
+
"standard-alignment",
|
|
14
|
+
"qti-standard-alignment",
|
|
15
|
+
"alignment-objective",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const STANDARD_ALIGNMENT_ATTRIBUTE_NAMES = new Set([
|
|
19
|
+
"standard-id",
|
|
20
|
+
"standardIdentifier",
|
|
21
|
+
"standard-framework",
|
|
22
|
+
"standardFramework",
|
|
23
|
+
"target-id",
|
|
24
|
+
"targetIdentifier",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export function parseTimingMetadata(
|
|
28
|
+
root: QtiPackageXmlNode,
|
|
29
|
+
sourcePath: string,
|
|
30
|
+
): QtiTimingMetadata | undefined {
|
|
31
|
+
const timeLimits = packageDescendants(root, "qti-time-limits")[0];
|
|
32
|
+
const timeDependent = parseXmlBoolean(root.attributes["time-dependent"]);
|
|
33
|
+
if (!timeLimits && timeDependent === undefined) return undefined;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
sourcePath,
|
|
37
|
+
timeDependent,
|
|
38
|
+
minTime: timeLimits?.attributes["min-time"],
|
|
39
|
+
maxTime: timeLimits?.attributes["max-time"],
|
|
40
|
+
allowLateSubmission: parseXmlBoolean(timeLimits?.attributes["allow-late-submission"]),
|
|
41
|
+
attributes: timeLimits ? { ...timeLimits.attributes } : {},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseStandardAlignments(
|
|
46
|
+
root: QtiPackageXmlNode,
|
|
47
|
+
sourcePath: string,
|
|
48
|
+
): QtiStandardAlignment[] {
|
|
49
|
+
const alignments: QtiStandardAlignment[] = [];
|
|
50
|
+
for (const node of packageDescendants(root)) {
|
|
51
|
+
if (!isStandardAlignmentNode(node)) continue;
|
|
52
|
+
const text = node.text.trim();
|
|
53
|
+
alignments.push({
|
|
54
|
+
sourcePath,
|
|
55
|
+
qtiName: node.localName,
|
|
56
|
+
identifier: firstAttribute(node, [
|
|
57
|
+
"identifier",
|
|
58
|
+
"id",
|
|
59
|
+
"standard-id",
|
|
60
|
+
"standardIdentifier",
|
|
61
|
+
"target-id",
|
|
62
|
+
"targetIdentifier",
|
|
63
|
+
]),
|
|
64
|
+
framework: firstAttribute(node, [
|
|
65
|
+
"framework",
|
|
66
|
+
"standard-framework",
|
|
67
|
+
"standardFramework",
|
|
68
|
+
"educational-framework",
|
|
69
|
+
"educationalFramework",
|
|
70
|
+
]),
|
|
71
|
+
targetName:
|
|
72
|
+
firstAttribute(node, ["target-name", "targetName", "name"]) ??
|
|
73
|
+
(text.length > 0 ? text : undefined),
|
|
74
|
+
targetUrl: firstAttribute(node, ["href", "url", "uri", "target-url", "targetUrl"]),
|
|
75
|
+
text: text.length > 0 ? text : undefined,
|
|
76
|
+
attributes: { ...node.attributes },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return alignments;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isStandardAlignmentNode(node: QtiPackageXmlNode): boolean {
|
|
83
|
+
if (STANDARD_ALIGNMENT_ELEMENT_NAMES.has(node.localName)) return true;
|
|
84
|
+
return Object.keys(node.attributes).some((name) => STANDARD_ALIGNMENT_ATTRIBUTE_NAMES.has(name));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function firstAttribute(node: QtiPackageXmlNode, names: readonly string[]): string | undefined {
|
|
88
|
+
for (const name of names) {
|
|
89
|
+
const value = node.attributes[name];
|
|
90
|
+
if (value && value.trim().length > 0) return value.trim();
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function uniqueStandards(
|
|
96
|
+
standards: readonly QtiStandardAlignment[],
|
|
97
|
+
): QtiStandardAlignment[] {
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
const unique: QtiStandardAlignment[] = [];
|
|
100
|
+
for (const standard of standards) {
|
|
101
|
+
const key = [
|
|
102
|
+
standard.sourcePath,
|
|
103
|
+
standard.qtiName,
|
|
104
|
+
standard.identifier ?? "",
|
|
105
|
+
standard.framework ?? "",
|
|
106
|
+
standard.targetName ?? "",
|
|
107
|
+
standard.targetUrl ?? "",
|
|
108
|
+
standard.text ?? "",
|
|
109
|
+
].join("\u0000");
|
|
110
|
+
if (seen.has(key)) continue;
|
|
111
|
+
seen.add(key);
|
|
112
|
+
unique.push(standard);
|
|
113
|
+
}
|
|
114
|
+
return unique;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function primaryTiming(
|
|
118
|
+
assessmentTest: QtiAssessmentTestPackageModel | undefined,
|
|
119
|
+
items: readonly QtiPackageItem[],
|
|
120
|
+
): QtiTimingMetadata | undefined {
|
|
121
|
+
if (assessmentTest?.timing) return assessmentTest.timing;
|
|
122
|
+
const timedItems = items.filter((item) => item.timing !== undefined);
|
|
123
|
+
return timedItems.length === 1 ? timedItems[0]?.timing : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function packageTitle(
|
|
127
|
+
manifestRoot: QtiPackageXmlNode | undefined,
|
|
128
|
+
assessmentTest: QtiAssessmentTestPackageModel | undefined,
|
|
129
|
+
items: readonly QtiPackageItem[],
|
|
130
|
+
): string {
|
|
131
|
+
const manifestTitle = manifestRoot
|
|
132
|
+
? packageDescendants(manifestRoot, "title")
|
|
133
|
+
.map((node) => node.text.trim())
|
|
134
|
+
.find((text) => text.length > 0)
|
|
135
|
+
: undefined;
|
|
136
|
+
return (
|
|
137
|
+
assessmentTest?.title ??
|
|
138
|
+
manifestTitle ??
|
|
139
|
+
items.find((item) => item.title !== undefined)?.title ??
|
|
140
|
+
manifestRoot?.attributes.identifier ??
|
|
141
|
+
""
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { QtiDiagnostic } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Add a package parser diagnostic to an existing diagnostics list. */
|
|
4
|
+
export function pushPackageDiagnostic(
|
|
5
|
+
diagnostics: QtiDiagnostic[],
|
|
6
|
+
code: string,
|
|
7
|
+
severity: QtiDiagnostic["severity"],
|
|
8
|
+
message: string,
|
|
9
|
+
path?: string,
|
|
10
|
+
): void {
|
|
11
|
+
diagnostics.push({
|
|
12
|
+
code,
|
|
13
|
+
severity,
|
|
14
|
+
message,
|
|
15
|
+
path,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Normalize a package-relative path and diagnose absolute or escaping paths. */
|
|
20
|
+
export function normalizePackagePath(
|
|
21
|
+
path: string,
|
|
22
|
+
context: string,
|
|
23
|
+
diagnostics: QtiDiagnostic[],
|
|
24
|
+
): string | undefined {
|
|
25
|
+
if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || /^[a-z][a-z0-9+.-]*:/i.test(path)) {
|
|
26
|
+
pushPackageDiagnostic(
|
|
27
|
+
diagnostics,
|
|
28
|
+
"package.path.absolute",
|
|
29
|
+
"error",
|
|
30
|
+
`${context} ${path} must be a package-relative path.`,
|
|
31
|
+
path,
|
|
32
|
+
);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parts: string[] = [];
|
|
37
|
+
for (const part of path.split("/")) {
|
|
38
|
+
if (!part || part === ".") continue;
|
|
39
|
+
if (part === "..") {
|
|
40
|
+
if (parts.length === 0) {
|
|
41
|
+
pushPackageDiagnostic(
|
|
42
|
+
diagnostics,
|
|
43
|
+
"package.path.escape",
|
|
44
|
+
"error",
|
|
45
|
+
`${context} ${path} escapes the package root.`,
|
|
46
|
+
path,
|
|
47
|
+
);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
parts.pop();
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
parts.push(part);
|
|
54
|
+
}
|
|
55
|
+
return parts.join("/");
|
|
56
|
+
}
|