@longsightgroup/qti3-migrator 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +13 -0
- package/dist/diagnostics.d.ts +7 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +7 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +262 -0
- package/dist/index.js.map +1 -0
- package/dist/options.d.ts +3 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +7 -0
- package/dist/options.js.map +1 -0
- package/dist/qti12-item.d.ts +7 -0
- package/dist/qti12-item.d.ts.map +1 -0
- package/dist/qti12-item.js +393 -0
- package/dist/qti12-item.js.map +1 -0
- package/dist/qti2-body.d.ts +11 -0
- package/dist/qti2-body.d.ts.map +1 -0
- package/dist/qti2-body.js +90 -0
- package/dist/qti2-body.js.map +1 -0
- package/dist/qti2-choices.d.ts +7 -0
- package/dist/qti2-choices.d.ts.map +1 -0
- package/dist/qti2-choices.js +74 -0
- package/dist/qti2-choices.js.map +1 -0
- package/dist/qti2-graphic.d.ts +10 -0
- package/dist/qti2-graphic.d.ts.map +1 -0
- package/dist/qti2-graphic.js +30 -0
- package/dist/qti2-graphic.js.map +1 -0
- package/dist/qti2-item.d.ts +8 -0
- package/dist/qti2-item.d.ts.map +1 -0
- package/dist/qti2-item.js +494 -0
- package/dist/qti2-item.js.map +1 -0
- package/dist/qti2-response.d.ts +9 -0
- package/dist/qti2-response.d.ts.map +1 -0
- package/dist/qti2-response.js +32 -0
- package/dist/qti2-response.js.map +1 -0
- package/dist/source.d.ts +30 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +197 -0
- package/dist/source.js.map +1 -0
- package/dist/text.d.ts +5 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +19 -0
- package/dist/text.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/xml.d.ts +16 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +101 -0
- package/dist/xml.js.map +1 -0
- package/package.json +56 -0
- package/src/diagnostics.ts +21 -0
- package/src/index.ts +393 -0
- package/src/options.ts +8 -0
- package/src/qti12-item.ts +507 -0
- package/src/qti2-body.ts +138 -0
- package/src/qti2-choices.ts +96 -0
- package/src/qti2-graphic.ts +32 -0
- package/src/qti2-item.ts +650 -0
- package/src/qti2-response.ts +41 -0
- package/src/source.ts +251 -0
- package/src/text.ts +21 -0
- package/src/types.ts +73 -0
- package/src/xml.ts +119 -0
package/src/source.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { unzipSync } from "fflate";
|
|
2
|
+
import { decodeUtf8 } from "@longsightgroup/qti3-core";
|
|
3
|
+
import { diagnostic } from "./diagnostics.js";
|
|
4
|
+
import type {
|
|
5
|
+
QtiMigrationDetectionResult,
|
|
6
|
+
QtiMigrationDiagnostic,
|
|
7
|
+
QtiMigrationSourceInput,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
import { attr, findAllDescendantsByLocalName, localName, parseXml, textOf } from "./xml.js";
|
|
10
|
+
|
|
11
|
+
export interface MigrationEntry {
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly bytes: Uint8Array;
|
|
14
|
+
readonly text?: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MigrationSource {
|
|
18
|
+
readonly filename?: string | undefined;
|
|
19
|
+
readonly isPackage: boolean;
|
|
20
|
+
readonly entries: readonly MigrationEntry[];
|
|
21
|
+
readonly xml?: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readMigrationSource(input: QtiMigrationSourceInput): MigrationSource {
|
|
25
|
+
if (input.xml !== undefined) {
|
|
26
|
+
return { filename: input.filename, isPackage: false, entries: [], xml: input.xml };
|
|
27
|
+
}
|
|
28
|
+
if (!input.bytes) {
|
|
29
|
+
throw new Error("QTI migration input must include xml or bytes.");
|
|
30
|
+
}
|
|
31
|
+
if (isZip(input.bytes)) {
|
|
32
|
+
const unzipped = unzipSync(input.bytes);
|
|
33
|
+
const entries = Object.entries(unzipped)
|
|
34
|
+
.filter(([path]) => !path.endsWith("/"))
|
|
35
|
+
.map(([path, bytes]) => ({
|
|
36
|
+
path: path.replaceAll("\\", "/"),
|
|
37
|
+
bytes,
|
|
38
|
+
text: isTextPath(path) ? decodeUtf8(bytes) : undefined,
|
|
39
|
+
}));
|
|
40
|
+
return { filename: input.filename, isPackage: true, entries };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
filename: input.filename,
|
|
44
|
+
isPackage: false,
|
|
45
|
+
entries: [],
|
|
46
|
+
xml: decodeUtf8(input.bytes),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function detectQtiMigrationSource(
|
|
51
|
+
input: QtiMigrationSourceInput,
|
|
52
|
+
): QtiMigrationDetectionResult {
|
|
53
|
+
try {
|
|
54
|
+
const source = readMigrationSource(input);
|
|
55
|
+
return detectMigrationSource(source);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return {
|
|
58
|
+
supported: false,
|
|
59
|
+
confidence: 0,
|
|
60
|
+
reason: error instanceof Error ? error.message : "source-read-failed",
|
|
61
|
+
isPackage: false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function detectMigrationSource(source: MigrationSource): QtiMigrationDetectionResult {
|
|
67
|
+
if (source.isPackage) {
|
|
68
|
+
const manifest = source.entries.find((entry) => entry.path.toLowerCase() === "imsmanifest.xml");
|
|
69
|
+
if (!manifest?.text) {
|
|
70
|
+
return {
|
|
71
|
+
supported: false,
|
|
72
|
+
confidence: 0.2,
|
|
73
|
+
reason: "package-manifest-missing",
|
|
74
|
+
isPackage: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return detectManifest(manifest.text);
|
|
78
|
+
}
|
|
79
|
+
if (!source.xml) {
|
|
80
|
+
return { supported: false, confidence: 0, reason: "xml-missing", isPackage: false };
|
|
81
|
+
}
|
|
82
|
+
return detectItemXml(source.xml);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface LegacyManifest {
|
|
86
|
+
readonly title: string;
|
|
87
|
+
readonly resources: readonly LegacyManifestResource[];
|
|
88
|
+
readonly itemHrefs: readonly string[];
|
|
89
|
+
readonly testHrefs: readonly string[];
|
|
90
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface LegacyManifestResource {
|
|
94
|
+
readonly identifier: string;
|
|
95
|
+
readonly type: string;
|
|
96
|
+
readonly href?: string | undefined;
|
|
97
|
+
readonly files: readonly string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseLegacyManifest(xml: string): LegacyManifest {
|
|
101
|
+
const diagnostics: QtiMigrationDiagnostic[] = [];
|
|
102
|
+
const doc = parseXml(xml, "imsmanifest.xml");
|
|
103
|
+
const root = doc.documentElement;
|
|
104
|
+
const title =
|
|
105
|
+
textOf(findAllDescendantsByLocalName(root, "title")[0]) ||
|
|
106
|
+
attr(root, "identifier") ||
|
|
107
|
+
"Imported QTI Package";
|
|
108
|
+
const resourceNodes = findAllDescendantsByLocalName(root, "resource");
|
|
109
|
+
const resources = resourceNodes.map((resource, index) => {
|
|
110
|
+
const files = findAllDescendantsByLocalName(resource, "file")
|
|
111
|
+
.map((file) => attr(file, "href"))
|
|
112
|
+
.filter((href): href is string => Boolean(href));
|
|
113
|
+
return {
|
|
114
|
+
identifier: attr(resource, "identifier") ?? `RESOURCE_${index + 1}`,
|
|
115
|
+
type: attr(resource, "type") ?? "",
|
|
116
|
+
href: attr(resource, "href") ?? files.find((file) => file.toLowerCase().endsWith(".xml")),
|
|
117
|
+
files,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
const itemHrefs = resources
|
|
121
|
+
.filter((resource) => isLegacyItemResource(resource.type))
|
|
122
|
+
.map((resource) => resource.href)
|
|
123
|
+
.filter((href): href is string => Boolean(href));
|
|
124
|
+
const testHrefs = resources
|
|
125
|
+
.filter((resource) => isLegacyTestResource(resource.type))
|
|
126
|
+
.map((resource) => resource.href)
|
|
127
|
+
.filter((href): href is string => Boolean(href));
|
|
128
|
+
if (!itemHrefs.length && !testHrefs.length) {
|
|
129
|
+
diagnostics.push(
|
|
130
|
+
diagnostic(
|
|
131
|
+
"manifest_items_missing",
|
|
132
|
+
"error",
|
|
133
|
+
"Manifest does not contain supported QTI 1.2 or QTI 2.x item/test resources.",
|
|
134
|
+
{ path: "imsmanifest.xml" },
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return { title, resources, itemHrefs, testHrefs, diagnostics };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function detectManifest(xml: string): QtiMigrationDetectionResult {
|
|
142
|
+
const manifest = parseLegacyManifest(xml);
|
|
143
|
+
const typeText = manifest.resources.map((resource) => resource.type.toLowerCase()).join(" ");
|
|
144
|
+
if (typeText.includes("v2p2")) {
|
|
145
|
+
return {
|
|
146
|
+
supported: true,
|
|
147
|
+
sourceFormat: "qti22",
|
|
148
|
+
confidence: 0.9,
|
|
149
|
+
reason: "manifest-qti22",
|
|
150
|
+
isPackage: true,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (typeText.includes("v2p1") || typeText.includes("imsqti_item_xmlv2")) {
|
|
154
|
+
return {
|
|
155
|
+
supported: true,
|
|
156
|
+
sourceFormat: "qti21",
|
|
157
|
+
confidence: 0.85,
|
|
158
|
+
reason: "manifest-qti2x",
|
|
159
|
+
isPackage: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (typeText.includes("qti_xmlv1p2") || typeText.includes("imsqti_xmlv1p2")) {
|
|
163
|
+
return {
|
|
164
|
+
supported: true,
|
|
165
|
+
sourceFormat: "qti12",
|
|
166
|
+
confidence: 0.85,
|
|
167
|
+
reason: "manifest-qti12",
|
|
168
|
+
isPackage: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
supported: manifest.itemHrefs.length > 0,
|
|
173
|
+
sourceFormat: manifest.itemHrefs.length > 0 ? "qti21" : undefined,
|
|
174
|
+
confidence: manifest.itemHrefs.length > 0 ? 0.45 : 0.1,
|
|
175
|
+
reason:
|
|
176
|
+
manifest.itemHrefs.length > 0 ? "manifest-legacy-item-resource" : "manifest-unsupported",
|
|
177
|
+
isPackage: true,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function detectItemXml(xml: string): QtiMigrationDetectionResult {
|
|
182
|
+
const doc = parseXml(xml, "item");
|
|
183
|
+
const root = doc.documentElement;
|
|
184
|
+
const rootName = localName(root);
|
|
185
|
+
const serializedRoot = root.nodeName.toLowerCase();
|
|
186
|
+
const namespaceText = [
|
|
187
|
+
attr(root, "xmlns"),
|
|
188
|
+
attr(root, "xmlns:qti"),
|
|
189
|
+
attr(root, "xsi:schemaLocation"),
|
|
190
|
+
attr(root, "schemaLocation"),
|
|
191
|
+
serializedRoot,
|
|
192
|
+
]
|
|
193
|
+
.join(" ")
|
|
194
|
+
.toLowerCase();
|
|
195
|
+
if (rootName === "assessmentitem") {
|
|
196
|
+
if (namespaceText.includes("v2p2")) {
|
|
197
|
+
return {
|
|
198
|
+
supported: true,
|
|
199
|
+
sourceFormat: "qti22",
|
|
200
|
+
confidence: 0.9,
|
|
201
|
+
reason: "assessmentitem-qti22",
|
|
202
|
+
isPackage: false,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
supported: true,
|
|
207
|
+
sourceFormat: "qti21",
|
|
208
|
+
confidence: 0.85,
|
|
209
|
+
reason: "assessmentitem-qti2x",
|
|
210
|
+
isPackage: false,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (rootName === "questestinterop" || rootName === "item") {
|
|
214
|
+
return {
|
|
215
|
+
supported: true,
|
|
216
|
+
sourceFormat: "qti12",
|
|
217
|
+
confidence: 0.75,
|
|
218
|
+
reason: "qti12-root",
|
|
219
|
+
isPackage: false,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
supported: false,
|
|
224
|
+
confidence: 0,
|
|
225
|
+
reason: `unsupported-root-${rootName}`,
|
|
226
|
+
isPackage: false,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isLegacyItemResource(type: string): boolean {
|
|
231
|
+
const value = type.toLowerCase();
|
|
232
|
+
return (
|
|
233
|
+
value.includes("imsqti_item_xmlv2") ||
|
|
234
|
+
value.includes("imsqti_item_xmlv1p2") ||
|
|
235
|
+
value.includes("imsqti_xmlv1p2") ||
|
|
236
|
+
value.includes("qti_xmlv1p2")
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function isLegacyTestResource(type: string): boolean {
|
|
241
|
+
const value = type.toLowerCase();
|
|
242
|
+
return value.includes("imsqti_test_xmlv2");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isZip(bytes: Uint8Array): boolean {
|
|
246
|
+
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isTextPath(path: string): boolean {
|
|
250
|
+
return /\.(xml|html?|txt|json|css|js)$/i.test(path);
|
|
251
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function normalizeIdentifier(value: string | null | undefined, fallback = "ID"): string {
|
|
2
|
+
const source = (value ?? "").trim() || fallback;
|
|
3
|
+
const normalized = source.replace(/[^A-Za-z0-9_]/g, "_").replace(/^[^A-Za-z_]+/, "");
|
|
4
|
+
return normalized || fallback;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function stripTags(html: string): string {
|
|
8
|
+
return html.replace(/<[^>]*>/g, "").trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function trustedFragment(html: string): string {
|
|
12
|
+
return html.trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function escapeText(value: string): string {
|
|
16
|
+
return value
|
|
17
|
+
.replaceAll("&", "&")
|
|
18
|
+
.replaceAll("<", "<")
|
|
19
|
+
.replaceAll(">", ">")
|
|
20
|
+
.replaceAll('"', """);
|
|
21
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Qti3AuthoringItem, Qti3WriterDiagnostic } from "@longsightgroup/qti3-writer";
|
|
2
|
+
|
|
3
|
+
export type QtiMigrationSourceFormat = "qti12" | "qti21" | "qti22";
|
|
4
|
+
|
|
5
|
+
export type QtiMigrationRepairPolicy = "none" | "safe";
|
|
6
|
+
|
|
7
|
+
export type QtiMigrationUnsupportedPolicy = "diagnostic" | "stub" | "skip";
|
|
8
|
+
|
|
9
|
+
export interface QtiMigrationSourceInput {
|
|
10
|
+
readonly filename?: string | undefined;
|
|
11
|
+
readonly bytes?: Uint8Array | undefined;
|
|
12
|
+
readonly xml?: string | undefined;
|
|
13
|
+
readonly mime?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface QtiMigrationOptions {
|
|
17
|
+
readonly repairPolicy?: QtiMigrationRepairPolicy | undefined;
|
|
18
|
+
readonly unsupportedPolicy?: QtiMigrationUnsupportedPolicy | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ResolvedQtiMigrationOptions {
|
|
22
|
+
readonly repairPolicy: QtiMigrationRepairPolicy;
|
|
23
|
+
readonly unsupportedPolicy: QtiMigrationUnsupportedPolicy;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface QtiMigrationDetectionResult {
|
|
27
|
+
readonly supported: boolean;
|
|
28
|
+
readonly sourceFormat?: QtiMigrationSourceFormat | undefined;
|
|
29
|
+
readonly confidence: number;
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
readonly isPackage: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type QtiMigrationDiagnosticSeverity = "info" | "warning" | "error";
|
|
35
|
+
|
|
36
|
+
export interface QtiMigrationDiagnostic {
|
|
37
|
+
readonly code: string;
|
|
38
|
+
readonly severity: QtiMigrationDiagnosticSeverity;
|
|
39
|
+
readonly message: string;
|
|
40
|
+
readonly path?: string | undefined;
|
|
41
|
+
readonly sourceFormat?: QtiMigrationSourceFormat | undefined;
|
|
42
|
+
readonly writerDiagnostics?: readonly Qti3WriterDiagnostic[] | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface QtiMigrationPart {
|
|
46
|
+
readonly identifier: string;
|
|
47
|
+
readonly title: string;
|
|
48
|
+
readonly itemHrefs: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface QtiMigrationAsset {
|
|
52
|
+
readonly path: string;
|
|
53
|
+
readonly data?: Uint8Array | undefined;
|
|
54
|
+
readonly mediaType?: string | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface QtiMigrationItemResult {
|
|
58
|
+
readonly identifier: string;
|
|
59
|
+
readonly title: string;
|
|
60
|
+
readonly href: string;
|
|
61
|
+
readonly authoringItem?: Qti3AuthoringItem | undefined;
|
|
62
|
+
readonly xml?: string | undefined;
|
|
63
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface QtiMigrationResult {
|
|
67
|
+
readonly title: string;
|
|
68
|
+
readonly sourceFormat?: QtiMigrationSourceFormat | undefined;
|
|
69
|
+
readonly parts: readonly QtiMigrationPart[];
|
|
70
|
+
readonly items: readonly QtiMigrationItemResult[];
|
|
71
|
+
readonly assets: readonly QtiMigrationAsset[];
|
|
72
|
+
readonly diagnostics: readonly QtiMigrationDiagnostic[];
|
|
73
|
+
}
|
package/src/xml.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
|
|
2
|
+
|
|
3
|
+
export type XmlDocument = Document;
|
|
4
|
+
export type XmlElement = Element;
|
|
5
|
+
export type XmlNode = Node;
|
|
6
|
+
|
|
7
|
+
const parser = new DOMParser();
|
|
8
|
+
const serializer = new XMLSerializer();
|
|
9
|
+
|
|
10
|
+
export function parseXml(xml: string, context: string): XmlDocument {
|
|
11
|
+
const doc = parser.parseFromString(normalizeXml(xml), "text/xml");
|
|
12
|
+
if (doc.documentElement.nodeName === "parsererror") {
|
|
13
|
+
throw new Error(`Failed to parse XML (${context}).`);
|
|
14
|
+
}
|
|
15
|
+
return doc;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function serializeNode(node: XmlNode): string {
|
|
19
|
+
return serializer.serializeToString(node);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function serializeChildren(element: XmlElement): string {
|
|
23
|
+
let out = "";
|
|
24
|
+
for (let index = 0; index < element.childNodes.length; index += 1) {
|
|
25
|
+
const child = element.childNodes.item(index);
|
|
26
|
+
out += serializeNode(child);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function attr(element: XmlElement | null | undefined, key: string): string | null {
|
|
32
|
+
if (!element) return null;
|
|
33
|
+
const alternate = key.includes("-")
|
|
34
|
+
? key.replace(/-([a-z])/g, (_match, char: string) => char.toUpperCase())
|
|
35
|
+
: key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
36
|
+
const target = key.toLowerCase();
|
|
37
|
+
for (let index = 0; index < element.attributes.length; index += 1) {
|
|
38
|
+
const attribute = element.attributes.item(index);
|
|
39
|
+
if (!attribute) continue;
|
|
40
|
+
if (
|
|
41
|
+
attribute.name === key ||
|
|
42
|
+
attribute.name === alternate ||
|
|
43
|
+
attribute.name.toLowerCase() === target
|
|
44
|
+
) {
|
|
45
|
+
return attribute.value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function localName(element: XmlElement | null | undefined): string {
|
|
52
|
+
const raw = element?.localName ?? element?.nodeName ?? "";
|
|
53
|
+
return (raw.includes(":") ? (raw.split(":").pop() ?? raw) : raw).toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function childElements(node: XmlElement): XmlElement[] {
|
|
57
|
+
const out: XmlElement[] = [];
|
|
58
|
+
for (let index = 0; index < node.childNodes.length; index += 1) {
|
|
59
|
+
const child = node.childNodes.item(index);
|
|
60
|
+
if (isXmlElement(child)) out.push(child);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function findDescendantByLocalName(
|
|
66
|
+
root: XmlElement | null | undefined,
|
|
67
|
+
tag: string,
|
|
68
|
+
): XmlElement | null {
|
|
69
|
+
if (!root) return null;
|
|
70
|
+
const target = tag.toLowerCase();
|
|
71
|
+
for (const child of childElements(root)) {
|
|
72
|
+
if (localName(child) === target) return child;
|
|
73
|
+
const nested = findDescendantByLocalName(child, target);
|
|
74
|
+
if (nested) return nested;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function findAllDescendantsByLocalName(
|
|
80
|
+
root: XmlElement | null | undefined,
|
|
81
|
+
tag: string,
|
|
82
|
+
): XmlElement[] {
|
|
83
|
+
return findAllDescendantsByAnyLocalName(root, [tag]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function findAllDescendantsByAnyLocalName(
|
|
87
|
+
root: XmlElement | null | undefined,
|
|
88
|
+
tags: readonly string[],
|
|
89
|
+
): XmlElement[] {
|
|
90
|
+
if (!root) return [];
|
|
91
|
+
const tagSet = new Set(tags.map((tag) => tag.toLowerCase()));
|
|
92
|
+
const matches: XmlElement[] = [];
|
|
93
|
+
const walk = (node: XmlElement): void => {
|
|
94
|
+
for (const child of childElements(node)) {
|
|
95
|
+
if (tagSet.has(localName(child))) matches.push(child);
|
|
96
|
+
if (child.childNodes.length) walk(child);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
walk(root);
|
|
100
|
+
return matches;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function toNumber(value: string | null | undefined): number | undefined {
|
|
104
|
+
if (value == null || value.trim() === "") return undefined;
|
|
105
|
+
const num = Number(value);
|
|
106
|
+
return Number.isFinite(num) ? num : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function textOf(element: XmlElement | null | undefined): string {
|
|
110
|
+
return (element?.textContent ?? "").trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isXmlElement(node: XmlNode | null): node is XmlElement {
|
|
114
|
+
return node?.nodeType === 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeXml(xml: string): string {
|
|
118
|
+
return xml.replace(/^\uFEFF/, "").trimStart();
|
|
119
|
+
}
|