execonvert 0.4.2 → 0.5.0
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/CHANGELOG.md +20 -0
- package/README.md +79 -0
- package/dist/cli/app/public/exelearning/app/common/common_i18n.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.ca.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.de.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.en.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.eo.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.es.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.eu.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.gl.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.it.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.pt.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.ro.js +4 -1
- package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.va.js +4 -1
- package/dist/cli/app/public/exelearning/bundles/common.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/content-css.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/idevices.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/libs.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/manifest.json +76 -75
- package/dist/cli/app/public/exelearning/bundles/themes/base.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/themes/flux.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/themes/neo.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/themes/nova.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/themes/universal.zip +0 -0
- package/dist/cli/app/public/exelearning/bundles/themes/zen.zip +0 -0
- package/dist/cli/app/public/exelearning/exporters.bundle.js +7250 -353
- package/dist/cli/app/public/exelearning/importers.bundle.js +693 -163
- package/dist/cli/app/public/exelearning/runtime-source.json +6 -6
- package/dist/cli/app/public/info/cli.html +105 -8
- package/dist/cli/cli/execonvert.js +47 -7
- package/dist/cli/cli/updates.js +315 -0
- package/package.json +9 -2
|
@@ -2708,9 +2708,9 @@
|
|
|
2708
2708
|
}
|
|
2709
2709
|
ProcessingInstruction.prototype.nodeType = PROCESSING_INSTRUCTION_NODE;
|
|
2710
2710
|
_extends(ProcessingInstruction, CharacterData);
|
|
2711
|
-
function
|
|
2711
|
+
function XMLSerializer2() {
|
|
2712
2712
|
}
|
|
2713
|
-
|
|
2713
|
+
XMLSerializer2.prototype.serializeToString = function(node, options) {
|
|
2714
2714
|
return nodeSerializeToString.call(node, options);
|
|
2715
2715
|
};
|
|
2716
2716
|
Node.prototype.toString = nodeSerializeToString;
|
|
@@ -3153,7 +3153,7 @@
|
|
|
3153
3153
|
exports.Text = Text2;
|
|
3154
3154
|
exports.ProcessingInstruction = ProcessingInstruction;
|
|
3155
3155
|
exports.walkDOM = walkDOM;
|
|
3156
|
-
exports.XMLSerializer =
|
|
3156
|
+
exports.XMLSerializer = XMLSerializer2;
|
|
3157
3157
|
}
|
|
3158
3158
|
});
|
|
3159
3159
|
|
|
@@ -7006,9 +7006,183 @@
|
|
|
7006
7006
|
return core.slice(openingEnd + 1, closing.start);
|
|
7007
7007
|
}
|
|
7008
7008
|
|
|
7009
|
+
// src/shared/import/unresolvedAssetRefs.ts
|
|
7010
|
+
var UNRESOLVED_REF = /\{\{context_path\}\}\/([^"'<>\s\\]+)/g;
|
|
7011
|
+
function collectUnresolvedAssetRefs(text) {
|
|
7012
|
+
if (!text || typeof text !== "string") return [];
|
|
7013
|
+
const paths = [];
|
|
7014
|
+
for (const match of text.matchAll(UNRESOLVED_REF)) {
|
|
7015
|
+
const path = match[1];
|
|
7016
|
+
if (path && !paths.includes(path)) paths.push(path);
|
|
7017
|
+
}
|
|
7018
|
+
return paths;
|
|
7019
|
+
}
|
|
7020
|
+
function addUnresolvedAssetRefs(report, componentId, ideviceType, text) {
|
|
7021
|
+
const paths = collectUnresolvedAssetRefs(text);
|
|
7022
|
+
if (paths.length === 0) return;
|
|
7023
|
+
let entry = report.find((item) => item.componentId === componentId);
|
|
7024
|
+
if (!entry) {
|
|
7025
|
+
entry = { componentId, ideviceType, paths: [] };
|
|
7026
|
+
report.push(entry);
|
|
7027
|
+
}
|
|
7028
|
+
for (const path of paths) {
|
|
7029
|
+
if (!entry.paths.includes(path)) entry.paths.push(path);
|
|
7030
|
+
}
|
|
7031
|
+
}
|
|
7032
|
+
|
|
7033
|
+
// src/shared/import/importPolicy.ts
|
|
7034
|
+
var MiB = 1024 * 1024;
|
|
7035
|
+
var CONSERVATIVE_ZIP_LIMITS = {
|
|
7036
|
+
maxTotalBytes: 500 * MiB,
|
|
7037
|
+
// 500 MiB cumulative
|
|
7038
|
+
maxEntryBytes: 200 * MiB,
|
|
7039
|
+
// 200 MiB per entry
|
|
7040
|
+
maxEntries: 1e4
|
|
7041
|
+
// entry-count cap
|
|
7042
|
+
};
|
|
7043
|
+
var DEFAULT_ZIP_LIMITS = CONSERVATIVE_ZIP_LIMITS;
|
|
7044
|
+
var DESKTOP_ZIP_LIMITS = {
|
|
7045
|
+
maxTotalBytes: 2048 * MiB,
|
|
7046
|
+
// 2 GiB cumulative
|
|
7047
|
+
maxEntryBytes: 1024 * MiB,
|
|
7048
|
+
// 1 GiB per entry
|
|
7049
|
+
maxEntries: 1e4
|
|
7050
|
+
// entry-count cap (same as conservative)
|
|
7051
|
+
};
|
|
7052
|
+
var DESKTOP_CONFIRM_ENTRY_BYTES = CONSERVATIVE_ZIP_LIMITS.maxEntryBytes;
|
|
7053
|
+
function getZipLimitsForRuntime(runtime) {
|
|
7054
|
+
return runtime === "desktop" ? DESKTOP_ZIP_LIMITS : CONSERVATIVE_ZIP_LIMITS;
|
|
7055
|
+
}
|
|
7056
|
+
function validateZipLimits(limits) {
|
|
7057
|
+
const { maxTotalBytes, maxEntryBytes, maxEntries } = limits ?? {};
|
|
7058
|
+
const isPositiveFinite = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
|
|
7059
|
+
if (!isPositiveFinite(maxTotalBytes)) {
|
|
7060
|
+
throw new TypeError(`Invalid maxTotalBytes: expected a positive finite number, got ${String(maxTotalBytes)}.`);
|
|
7061
|
+
}
|
|
7062
|
+
if (!isPositiveFinite(maxEntryBytes)) {
|
|
7063
|
+
throw new TypeError(`Invalid maxEntryBytes: expected a positive finite number, got ${String(maxEntryBytes)}.`);
|
|
7064
|
+
}
|
|
7065
|
+
if (!isPositiveFinite(maxEntries) || !Number.isInteger(maxEntries)) {
|
|
7066
|
+
throw new TypeError(`Invalid maxEntries: expected a positive integer, got ${String(maxEntries)}.`);
|
|
7067
|
+
}
|
|
7068
|
+
if (maxEntryBytes > maxTotalBytes) {
|
|
7069
|
+
throw new RangeError(
|
|
7070
|
+
`Invalid limits: maxEntryBytes (${maxEntryBytes}) cannot exceed maxTotalBytes (${maxTotalBytes}).`
|
|
7071
|
+
);
|
|
7072
|
+
}
|
|
7073
|
+
return limits;
|
|
7074
|
+
}
|
|
7075
|
+
var ZipLimitError = class _ZipLimitError extends Error {
|
|
7076
|
+
constructor(message, details) {
|
|
7077
|
+
super(message);
|
|
7078
|
+
this.name = "ZipLimitError";
|
|
7079
|
+
this.details = details;
|
|
7080
|
+
Object.setPrototypeOf(this, _ZipLimitError.prototype);
|
|
7081
|
+
}
|
|
7082
|
+
};
|
|
7083
|
+
function entrySizeError(label, entryName, actual, limit) {
|
|
7084
|
+
return new ZipLimitError(
|
|
7085
|
+
`Entry '${entryName}' in ${label} is too large when decompressed (${actual} bytes > ${limit} byte limit).`,
|
|
7086
|
+
{ kind: "entry-size", archiveLabel: label, entryName, actualValue: actual, limitValue: limit }
|
|
7087
|
+
);
|
|
7088
|
+
}
|
|
7089
|
+
function totalSizeError(label, actual, limit) {
|
|
7090
|
+
return new ZipLimitError(
|
|
7091
|
+
`${label} exceeds the maximum total decompressed size (${actual} bytes > ${limit} byte limit).`,
|
|
7092
|
+
{ kind: "total-size", archiveLabel: label, actualValue: actual, limitValue: limit }
|
|
7093
|
+
);
|
|
7094
|
+
}
|
|
7095
|
+
function entryCountError(label, actual, limit) {
|
|
7096
|
+
return new ZipLimitError(`${label} exceeds the maximum allowed number of entries (${limit}).`, {
|
|
7097
|
+
kind: "entry-count",
|
|
7098
|
+
archiveLabel: label,
|
|
7099
|
+
actualValue: actual,
|
|
7100
|
+
limitValue: limit
|
|
7101
|
+
});
|
|
7102
|
+
}
|
|
7103
|
+
function assertInspectionWithinLimits(inspection, limits, label) {
|
|
7104
|
+
validateZipLimits(limits);
|
|
7105
|
+
if (inspection.entryCount > limits.maxEntries) {
|
|
7106
|
+
throw entryCountError(label, inspection.entryCount, limits.maxEntries);
|
|
7107
|
+
}
|
|
7108
|
+
if (inspection.largestEntry && inspection.largestEntry.size > limits.maxEntryBytes) {
|
|
7109
|
+
throw entrySizeError(label, inspection.largestEntry.name, inspection.largestEntry.size, limits.maxEntryBytes);
|
|
7110
|
+
}
|
|
7111
|
+
if (inspection.totalBytes > limits.maxTotalBytes) {
|
|
7112
|
+
throw totalSizeError(label, inspection.totalBytes, limits.maxTotalBytes);
|
|
7113
|
+
}
|
|
7114
|
+
}
|
|
7115
|
+
var ImportCancelledError = class _ImportCancelledError extends Error {
|
|
7116
|
+
constructor(message = "Import cancelled by user") {
|
|
7117
|
+
super(message);
|
|
7118
|
+
this.name = "ImportCancelledError";
|
|
7119
|
+
Object.setPrototypeOf(this, _ImportCancelledError.prototype);
|
|
7120
|
+
}
|
|
7121
|
+
};
|
|
7122
|
+
function getDesktopExportCompatibility(assets, limits = DESKTOP_ZIP_LIMITS) {
|
|
7123
|
+
let totalBytes = 0;
|
|
7124
|
+
let largestAsset = null;
|
|
7125
|
+
let oversizedAsset = null;
|
|
7126
|
+
for (const asset of assets) {
|
|
7127
|
+
const size = Number.isFinite(asset.size) && asset.size > 0 ? asset.size : 0;
|
|
7128
|
+
totalBytes += size;
|
|
7129
|
+
if (largestAsset === null || size > largestAsset.size) {
|
|
7130
|
+
largestAsset = { name: asset.name, size };
|
|
7131
|
+
}
|
|
7132
|
+
if (size > limits.maxEntryBytes && (oversizedAsset === null || size > oversizedAsset.size)) {
|
|
7133
|
+
oversizedAsset = { name: asset.name, size };
|
|
7134
|
+
}
|
|
7135
|
+
}
|
|
7136
|
+
const exceedsTotal = totalBytes > limits.maxTotalBytes;
|
|
7137
|
+
return {
|
|
7138
|
+
compatible: oversizedAsset === null && !exceedsTotal,
|
|
7139
|
+
totalBytes,
|
|
7140
|
+
entryLimit: limits.maxEntryBytes,
|
|
7141
|
+
totalLimit: limits.maxTotalBytes,
|
|
7142
|
+
largestAsset,
|
|
7143
|
+
oversizedAsset,
|
|
7144
|
+
exceedsTotal
|
|
7145
|
+
};
|
|
7146
|
+
}
|
|
7147
|
+
function formatBytes(bytes) {
|
|
7148
|
+
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
7149
|
+
return "0 B";
|
|
7150
|
+
}
|
|
7151
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
7152
|
+
let value = bytes;
|
|
7153
|
+
let unitIndex = 0;
|
|
7154
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
7155
|
+
value /= 1024;
|
|
7156
|
+
unitIndex += 1;
|
|
7157
|
+
}
|
|
7158
|
+
if (unitIndex === 0) {
|
|
7159
|
+
return `${value} B`;
|
|
7160
|
+
}
|
|
7161
|
+
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
|
7162
|
+
}
|
|
7163
|
+
|
|
7009
7164
|
// src/shared/import/LegacyXmlParser.ts
|
|
7010
7165
|
var import_xmldom = __toESM(require_lib());
|
|
7011
7166
|
|
|
7167
|
+
// src/shared/import/resolveFieldInstances.ts
|
|
7168
|
+
function resolveFieldInstances(listEl, resolveReference) {
|
|
7169
|
+
const result = [];
|
|
7170
|
+
const children = Array.from(listEl.childNodes).filter((node) => node.nodeType === 1);
|
|
7171
|
+
for (const child of children) {
|
|
7172
|
+
if (child.tagName === "instance") {
|
|
7173
|
+
result.push(child);
|
|
7174
|
+
} else if (child.tagName === "reference") {
|
|
7175
|
+
const key = child.getAttribute("key");
|
|
7176
|
+
if (!key || !resolveReference) continue;
|
|
7177
|
+
const referenced = resolveReference(key);
|
|
7178
|
+
if (referenced) {
|
|
7179
|
+
result.push(referenced);
|
|
7180
|
+
}
|
|
7181
|
+
}
|
|
7182
|
+
}
|
|
7183
|
+
return result;
|
|
7184
|
+
}
|
|
7185
|
+
|
|
7012
7186
|
// src/shared/import/legacy-handlers/BaseLegacyHandler.ts
|
|
7013
7187
|
var BaseLegacyHandler = class {
|
|
7014
7188
|
/**
|
|
@@ -7325,8 +7499,20 @@
|
|
|
7325
7499
|
*/
|
|
7326
7500
|
decodeHtmlContent(content) {
|
|
7327
7501
|
if (!content) return "";
|
|
7328
|
-
const
|
|
7329
|
-
|
|
7502
|
+
const decodedContent = content.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/ /g, "\xA0");
|
|
7503
|
+
let token;
|
|
7504
|
+
do {
|
|
7505
|
+
token = `\0LTX${Math.random().toString(36).slice(2)}\0`;
|
|
7506
|
+
} while (decodedContent.includes(token));
|
|
7507
|
+
const latexPattern = /\\\((?:[^\\]|\\.)*?\\\)|\\\[(?:[^\\]|\\.)*?\\\]|\\begin\{[^}]+\}(?:[^\\]|\\.)*?\\end\{[^}]+\}|\$\$(?:[^$]|\\.)*?\$\$|(?<!\\)\$(?!\d+(?:[.,]\d+)?\b)(?:[^$\\]|\\.)*?(?<!\\)\$/g;
|
|
7508
|
+
const latexBlocks = [];
|
|
7509
|
+
const protectedContent = decodedContent.replace(latexPattern, (match) => {
|
|
7510
|
+
latexBlocks.push(match);
|
|
7511
|
+
return `${token}${latexBlocks.length - 1}${token}`;
|
|
7512
|
+
});
|
|
7513
|
+
const finalDecoded = protectedContent.replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\r(?![a-zA-Z])/g, "\r");
|
|
7514
|
+
const tokenPattern = new RegExp(`${token}(\\d+)${token}`, "g");
|
|
7515
|
+
return finalDecoded.replace(tokenPattern, (_, i) => latexBlocks[Number(i)]);
|
|
7330
7516
|
}
|
|
7331
7517
|
/**
|
|
7332
7518
|
* Remove legacy outer wrapper <div class="exe-text">...</div> when present.
|
|
@@ -7378,10 +7564,17 @@
|
|
|
7378
7564
|
/**
|
|
7379
7565
|
* Extract content from "fields" list (JsIdevice format)
|
|
7380
7566
|
*
|
|
7567
|
+
* The `fields` list is the authoritative source of an iDevice's content. It may
|
|
7568
|
+
* hold inline `<instance>` fields and/or `<reference key="N">` back-pointers to
|
|
7569
|
+
* fields serialized elsewhere in the document, so both must be resolved. When a
|
|
7570
|
+
* reference resolver is available (via `context.resolveReference`) the referenced
|
|
7571
|
+
* field is read too; otherwise references are skipped. See issue #2159.
|
|
7572
|
+
*
|
|
7381
7573
|
* @param dict - Dictionary element
|
|
7574
|
+
* @param context - Optional handler context providing the reference resolver
|
|
7382
7575
|
* @returns Combined content from text fields
|
|
7383
7576
|
*/
|
|
7384
|
-
extractFieldsContent(dict) {
|
|
7577
|
+
extractFieldsContent(dict, context) {
|
|
7385
7578
|
const children = this.getChildElements(dict);
|
|
7386
7579
|
for (let i = 0; i < children.length; i++) {
|
|
7387
7580
|
const child = children[i];
|
|
@@ -7389,7 +7582,7 @@
|
|
|
7389
7582
|
const listEl = children[i + 1];
|
|
7390
7583
|
if (listEl && listEl.tagName === "list") {
|
|
7391
7584
|
const contents = [];
|
|
7392
|
-
const fieldInstances =
|
|
7585
|
+
const fieldInstances = resolveFieldInstances(listEl, context?.resolveReference);
|
|
7393
7586
|
for (const fieldInst of fieldInstances) {
|
|
7394
7587
|
const fieldClass = fieldInst.getAttribute("class") || "";
|
|
7395
7588
|
if (fieldClass.includes("TextAreaField") || fieldClass.includes("TextField")) {
|
|
@@ -7431,35 +7624,62 @@
|
|
|
7431
7624
|
return "";
|
|
7432
7625
|
}
|
|
7433
7626
|
/**
|
|
7434
|
-
* Extract content from any TextAreaField or TextField
|
|
7627
|
+
* Extract content from any TextAreaField or TextField reachable within this
|
|
7628
|
+
* iDevice's own object subtree (last-resort fallback).
|
|
7435
7629
|
*
|
|
7436
|
-
*
|
|
7630
|
+
* The search is boundary-safe: it never descends into a nested iDevice or Node
|
|
7631
|
+
* `<instance>`. In the legacy pickle graph an iDevice inlines its own
|
|
7632
|
+
* `_idevice` / `parentNode` / `parent` back-references as full `<instance>`
|
|
7633
|
+
* elements, so an unbounded descendant search would cross into a *different*
|
|
7634
|
+
* iDevice and return its content. Honouring the iDevice/Node boundary keeps the
|
|
7635
|
+
* fallback scoped to the current iDevice. See issue #2159.
|
|
7636
|
+
*
|
|
7637
|
+
* @param dict - Dictionary element of the current iDevice
|
|
7437
7638
|
* @returns Content or empty string
|
|
7438
7639
|
*/
|
|
7439
7640
|
extractAnyTextFieldContent(dict) {
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
7641
|
+
return this.findBoundedTextFieldContent(dict);
|
|
7642
|
+
}
|
|
7643
|
+
/**
|
|
7644
|
+
* Depth-first search for a TextAreaField/TextField instance that stays inside
|
|
7645
|
+
* the current iDevice's own subtree (does not cross iDevice/Node boundaries).
|
|
7646
|
+
*
|
|
7647
|
+
* @param element - Element to search within
|
|
7648
|
+
* @returns Content of the first matching field, or empty string
|
|
7649
|
+
*/
|
|
7650
|
+
findBoundedTextFieldContent(element) {
|
|
7651
|
+
const children = this.getChildElements(element);
|
|
7652
|
+
for (const child of children) {
|
|
7653
|
+
if (child.tagName === "instance") {
|
|
7654
|
+
const className = child.getAttribute("class") || "";
|
|
7655
|
+
if (className.includes("TextAreaField") || className.includes("TextField")) {
|
|
7656
|
+
const content = this.extractTextAreaFieldContent(child);
|
|
7657
|
+
if (content) {
|
|
7658
|
+
return content;
|
|
7659
|
+
}
|
|
7660
|
+
continue;
|
|
7447
7661
|
}
|
|
7448
|
-
|
|
7449
|
-
|
|
7450
|
-
const nestedInstances = dict.getElementsByTagName("instance");
|
|
7451
|
-
for (let i = 0; i < nestedInstances.length; i++) {
|
|
7452
|
-
const inst = nestedInstances[i];
|
|
7453
|
-
const className = inst.getAttribute("class") || "";
|
|
7454
|
-
if (className.includes("TextAreaField") || className.includes("TextField")) {
|
|
7455
|
-
const content = this.extractTextAreaFieldContent(inst);
|
|
7456
|
-
if (content) {
|
|
7457
|
-
return content;
|
|
7662
|
+
if (this.isIdeviceOrNodeClass(className)) {
|
|
7663
|
+
continue;
|
|
7458
7664
|
}
|
|
7459
7665
|
}
|
|
7666
|
+
const nested = this.findBoundedTextFieldContent(child);
|
|
7667
|
+
if (nested) {
|
|
7668
|
+
return nested;
|
|
7669
|
+
}
|
|
7460
7670
|
}
|
|
7461
7671
|
return "";
|
|
7462
7672
|
}
|
|
7673
|
+
/**
|
|
7674
|
+
* Whether a legacy class name denotes an iDevice or a Node (a content boundary).
|
|
7675
|
+
*
|
|
7676
|
+
* @param className - Legacy class attribute value
|
|
7677
|
+
* @returns true for iDevice / Node classes
|
|
7678
|
+
*/
|
|
7679
|
+
isIdeviceOrNodeClass(className) {
|
|
7680
|
+
const lower = className.toLowerCase();
|
|
7681
|
+
return lower.includes("idevice") || lower.includes(".node.node") || lower.endsWith(".node");
|
|
7682
|
+
}
|
|
7463
7683
|
/**
|
|
7464
7684
|
* Extract resource path from dictionary
|
|
7465
7685
|
* Used for extracting file paths from resource instances
|
|
@@ -7486,6 +7706,14 @@
|
|
|
7486
7706
|
canHandle(_className, _ideviceType) {
|
|
7487
7707
|
return true;
|
|
7488
7708
|
}
|
|
7709
|
+
/**
|
|
7710
|
+
* This is the generic catch-all handler.
|
|
7711
|
+
* Its best-effort content must never overwrite an htmlView the parser already
|
|
7712
|
+
* resolved from an authoritative field reference (issue #2159).
|
|
7713
|
+
*/
|
|
7714
|
+
isFallback() {
|
|
7715
|
+
return true;
|
|
7716
|
+
}
|
|
7489
7717
|
/**
|
|
7490
7718
|
* Default to 'text' iDevice for unknown types
|
|
7491
7719
|
*/
|
|
@@ -7495,9 +7723,9 @@
|
|
|
7495
7723
|
/**
|
|
7496
7724
|
* Try to extract HTML content from various common fields
|
|
7497
7725
|
*/
|
|
7498
|
-
extractHtmlView(dict,
|
|
7726
|
+
extractHtmlView(dict, context) {
|
|
7499
7727
|
if (!dict) return "";
|
|
7500
|
-
const fieldsResult = this.extractFieldsContent(dict);
|
|
7728
|
+
const fieldsResult = this.extractFieldsContent(dict, context);
|
|
7501
7729
|
if (fieldsResult) {
|
|
7502
7730
|
return this.stripLegacyExeTextWrapper(fieldsResult);
|
|
7503
7731
|
}
|
|
@@ -8142,7 +8370,7 @@
|
|
|
8142
8370
|
if (contentInst) {
|
|
8143
8371
|
const clozeDict = this.getDirectChildByTagName(contentInst, "dictionary");
|
|
8144
8372
|
if (clozeDict) {
|
|
8145
|
-
const encodedContent = this.findDictStringValue(clozeDict, "_encodedContent");
|
|
8373
|
+
const encodedContent = this.findDictStringValue(clozeDict, "content_w_resourcePaths") || this.findDictStringValue(clozeDict, "_encodedContent");
|
|
8146
8374
|
if (encodedContent) {
|
|
8147
8375
|
const parsedText = this.parseClozeText(encodedContent);
|
|
8148
8376
|
if (parsedText.baseText) {
|
|
@@ -8160,7 +8388,7 @@
|
|
|
8160
8388
|
if (clozeInst) {
|
|
8161
8389
|
const clozeDict = this.getDirectChildByTagName(clozeInst, "dictionary");
|
|
8162
8390
|
if (clozeDict) {
|
|
8163
|
-
const clozeText = this.findDictStringValue(clozeDict, "_encodedContent") || this.findDictStringValue(clozeDict, "_clozeText") || this.findDictStringValue(clozeDict, "clozeText");
|
|
8391
|
+
const clozeText = this.findDictStringValue(clozeDict, "content_w_resourcePaths") || this.findDictStringValue(clozeDict, "_encodedContent") || this.findDictStringValue(clozeDict, "_clozeText") || this.findDictStringValue(clozeDict, "clozeText");
|
|
8164
8392
|
if (clozeText) {
|
|
8165
8393
|
const parsedText = this.parseClozeText(clozeText);
|
|
8166
8394
|
if (parsedText.baseText) {
|
|
@@ -8180,7 +8408,7 @@
|
|
|
8180
8408
|
if (clozeFieldByClass) {
|
|
8181
8409
|
const clozeDict = this.getDirectChildByTagName(clozeFieldByClass, "dictionary");
|
|
8182
8410
|
if (clozeDict) {
|
|
8183
|
-
const encodedContent = this.findDictStringValue(clozeDict, "_encodedContent");
|
|
8411
|
+
const encodedContent = this.findDictStringValue(clozeDict, "content_w_resourcePaths") || this.findDictStringValue(clozeDict, "_encodedContent");
|
|
8184
8412
|
if (encodedContent) {
|
|
8185
8413
|
const parsedText = this.parseClozeText(encodedContent);
|
|
8186
8414
|
if (parsedText.baseText) {
|
|
@@ -8412,7 +8640,7 @@
|
|
|
8412
8640
|
extractSingleListaField(listaFieldInst) {
|
|
8413
8641
|
const qDict = this.getDirectChildByTagName(listaFieldInst, "dictionary");
|
|
8414
8642
|
if (!qDict) return null;
|
|
8415
|
-
let baseText = this.findDictStringValue(qDict, "
|
|
8643
|
+
let baseText = this.findDictStringValue(qDict, "content_w_resourcePaths") || this.findDictStringValue(qDict, "_encodedContent") || "";
|
|
8416
8644
|
if (!baseText) {
|
|
8417
8645
|
const questionTextArea = this.findDictInstance(qDict, "questionTextArea");
|
|
8418
8646
|
baseText = questionTextArea ? this.extractTextAreaFieldContent(questionTextArea) : "";
|
|
@@ -8920,62 +9148,95 @@
|
|
|
8920
9148
|
// src/shared/import/legacy-handlers/FileAttachHandler.ts
|
|
8921
9149
|
var FileAttachHandler = class extends BaseLegacyHandler {
|
|
8922
9150
|
/**
|
|
8923
|
-
* Check if this handler can process the given legacy class
|
|
9151
|
+
* Check if this handler can process the given legacy class.
|
|
8924
9152
|
*/
|
|
8925
9153
|
canHandle(className, _ideviceType) {
|
|
8926
9154
|
return className.includes("FileAttachIdevice") || className.includes("AttachmentIdevice");
|
|
8927
9155
|
}
|
|
8928
9156
|
/**
|
|
8929
|
-
*
|
|
8930
|
-
* Symfony converts to 'text' iDevice with file links in textTextarea
|
|
9157
|
+
* Modern target type: the restored file-attachment iDevice.
|
|
8931
9158
|
*/
|
|
8932
9159
|
getTargetType() {
|
|
8933
|
-
return "
|
|
9160
|
+
return "file-attachment";
|
|
8934
9161
|
}
|
|
8935
9162
|
/**
|
|
8936
|
-
*
|
|
9163
|
+
* Build an immediate static view for newly imported legacy components.
|
|
8937
9164
|
*
|
|
8938
|
-
*
|
|
8939
|
-
* -
|
|
8940
|
-
*
|
|
9165
|
+
* The canonical state remains in JSON properties and the modern iDevice will
|
|
9166
|
+
* re-render from that state. The fallback htmlView is required because the
|
|
9167
|
+
* workarea displays imported htmlView before the iDevice has ever been opened
|
|
9168
|
+
* and saved; without it, only the generically extracted intro was visible.
|
|
8941
9169
|
*/
|
|
8942
9170
|
extractHtmlView(dict, _context) {
|
|
8943
9171
|
if (!dict) return "";
|
|
8944
|
-
const
|
|
8945
|
-
const
|
|
8946
|
-
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
9172
|
+
const properties = this.extractProperties(dict);
|
|
9173
|
+
const intro = typeof properties.intro === "string" ? properties.intro : "";
|
|
9174
|
+
const attachments = Array.isArray(properties.attachments) ? properties.attachments : [];
|
|
9175
|
+
if (attachments.length === 0) return "";
|
|
9176
|
+
const parts = ['<div class="fileAttachment-IDevice">'];
|
|
9177
|
+
if (intro.trim()) {
|
|
9178
|
+
parts.push(`<div class="fileAttachment-intro">${intro}</div>`);
|
|
9179
|
+
}
|
|
9180
|
+
parts.push('<ul class="fileAttachment-list">');
|
|
9181
|
+
for (const attachment of attachments) {
|
|
9182
|
+
const label = attachment.title || attachment.filename || "Attachment";
|
|
9183
|
+
const filename = attachment.filename || label;
|
|
9184
|
+
const meta = attachment.title && attachment.filename && attachment.title !== attachment.filename ? `<span class="fileAttachment-meta">${this.escapeHtml(attachment.filename)}</span>` : "";
|
|
9185
|
+
parts.push(
|
|
9186
|
+
`<li class="fileAttachment-item fileAttachment-item--file"><a class="fileAttachment-link" href="${this.escapeAttr(attachment.url)}" download="${this.escapeAttr(filename)}"><span class="fileAttachment-text"><span class="fileAttachment-title">${this.escapeHtml(label)}</span>` + meta + `</span></a></li>`
|
|
9187
|
+
);
|
|
8956
9188
|
}
|
|
9189
|
+
parts.push("</ul>");
|
|
9190
|
+
parts.push("</div>");
|
|
8957
9191
|
return parts.join("");
|
|
8958
9192
|
}
|
|
8959
9193
|
/**
|
|
8960
|
-
* No feedback for file
|
|
9194
|
+
* No feedback for the file-attachment iDevice.
|
|
8961
9195
|
*/
|
|
8962
9196
|
extractFeedback(_dict, _context) {
|
|
8963
9197
|
return { content: "", buttonCaption: "" };
|
|
8964
9198
|
}
|
|
8965
9199
|
/**
|
|
8966
|
-
*
|
|
8967
|
-
|
|
8968
|
-
|
|
8969
|
-
|
|
8970
|
-
|
|
8971
|
-
|
|
8972
|
-
|
|
8973
|
-
|
|
8974
|
-
|
|
8975
|
-
|
|
9200
|
+
* Build the modern file-attachment JSON state.
|
|
9201
|
+
*/
|
|
9202
|
+
extractProperties(dict, _ideviceId) {
|
|
9203
|
+
if (!dict) return {};
|
|
9204
|
+
const intro = this.extractIntroHtml(dict);
|
|
9205
|
+
const showDescriptions = this.extractShowDesc(dict);
|
|
9206
|
+
const attachments = this.extractFiles(dict).map((file) => this.toAttachment(file));
|
|
9207
|
+
return {
|
|
9208
|
+
intro,
|
|
9209
|
+
showDescriptions,
|
|
9210
|
+
attachments
|
|
9211
|
+
};
|
|
9212
|
+
}
|
|
9213
|
+
/**
|
|
9214
|
+
* Convert a legacy file entry into a modern attachment.
|
|
8976
9215
|
*
|
|
8977
|
-
*
|
|
8978
|
-
*
|
|
9216
|
+
* The legacy file description was displayed as the link label, so it maps to
|
|
9217
|
+
* the modern attachment `title`. When no real description/display name is
|
|
9218
|
+
* present we leave the title empty and let the iDevice fall back to the
|
|
9219
|
+
* filename.
|
|
9220
|
+
*/
|
|
9221
|
+
toAttachment(file) {
|
|
9222
|
+
let title = "";
|
|
9223
|
+
if (file.description && file.description !== file.filename) {
|
|
9224
|
+
title = file.description;
|
|
9225
|
+
} else if (file.displayName && file.displayName !== file.filename) {
|
|
9226
|
+
title = file.displayName;
|
|
9227
|
+
}
|
|
9228
|
+
return {
|
|
9229
|
+
url: file.path,
|
|
9230
|
+
// resources/<filename> -> rewritten to asset:// by the importer
|
|
9231
|
+
filename: file.filename,
|
|
9232
|
+
mimeType: "",
|
|
9233
|
+
size: 0,
|
|
9234
|
+
title,
|
|
9235
|
+
description: ""
|
|
9236
|
+
};
|
|
9237
|
+
}
|
|
9238
|
+
/**
|
|
9239
|
+
* Extract introHTML content (instructions text).
|
|
8979
9240
|
*/
|
|
8980
9241
|
extractIntroHtml(dict) {
|
|
8981
9242
|
const introInstance = this.findDictInstance(dict, "introHTML");
|
|
@@ -8983,26 +9244,28 @@
|
|
|
8983
9244
|
return this.extractTextAreaFieldContent(introInstance);
|
|
8984
9245
|
}
|
|
8985
9246
|
/**
|
|
8986
|
-
*
|
|
8987
|
-
*
|
|
8988
|
-
* Symfony sets textTextarea with the same HTML as htmlView
|
|
9247
|
+
* Read the legacy `showDesc` flag. Defaults to true when the key is absent,
|
|
9248
|
+
* mirroring the legacy default of showing file descriptions.
|
|
8989
9249
|
*/
|
|
8990
|
-
|
|
8991
|
-
const
|
|
8992
|
-
|
|
8993
|
-
|
|
9250
|
+
extractShowDesc(dict) {
|
|
9251
|
+
const children = this.getChildElements(dict);
|
|
9252
|
+
for (let i = 0; i < children.length; i++) {
|
|
9253
|
+
const child = children[i];
|
|
9254
|
+
if (child.tagName === "string" && child.getAttribute("role") === "key" && (child.getAttribute("value") === "showDesc" || child.getAttribute("value") === "_showDesc")) {
|
|
9255
|
+
const valueEl = children[i + 1];
|
|
9256
|
+
if (valueEl && valueEl.tagName === "bool") {
|
|
9257
|
+
return valueEl.getAttribute("value") === "1";
|
|
9258
|
+
}
|
|
9259
|
+
}
|
|
8994
9260
|
}
|
|
8995
|
-
return
|
|
9261
|
+
return true;
|
|
8996
9262
|
}
|
|
8997
9263
|
/**
|
|
8998
|
-
* Extract files from the legacy format
|
|
9264
|
+
* Extract files from the legacy format.
|
|
8999
9265
|
*
|
|
9000
9266
|
* FileAttachIdeviceInc structure:
|
|
9001
9267
|
* - fileAttachmentFields: list of FileField instances
|
|
9002
9268
|
* - Each FileField has: fileDescription (TextField), fileResource (Resource)
|
|
9003
|
-
*
|
|
9004
|
-
* @param dict - Dictionary element of the FileAttachIdevice
|
|
9005
|
-
* @returns Array of file objects
|
|
9006
9269
|
*/
|
|
9007
9270
|
extractFiles(dict) {
|
|
9008
9271
|
const files = [];
|
|
@@ -9042,13 +9305,7 @@
|
|
|
9042
9305
|
return files;
|
|
9043
9306
|
}
|
|
9044
9307
|
/**
|
|
9045
|
-
* Extract file info from a dictionary
|
|
9046
|
-
*
|
|
9047
|
-
* FileAttachIdeviceInc FileField structure:
|
|
9048
|
-
* - fileResource: Resource with _storageName (filename in ZIP)
|
|
9049
|
-
* - fileDescription: TextField with content (description for link text)
|
|
9050
|
-
*
|
|
9051
|
-
* Based on Symfony OdeOldXmlFileAttachIdevice.php extraction
|
|
9308
|
+
* Extract file info from a FileField dictionary.
|
|
9052
9309
|
*/
|
|
9053
9310
|
extractFileFromDict(fDict) {
|
|
9054
9311
|
const filename = this.extractResourcePath(fDict, "fileResource") || this.extractResourcePath(fDict, "_fileResource") || this.extractResourcePath(fDict, "_resource") || this.findDictStringValue(fDict, "_storageName") || this.findDictStringValue(fDict, "storageName");
|
|
@@ -9064,34 +9321,34 @@
|
|
|
9064
9321
|
if (!description) {
|
|
9065
9322
|
description = this.findDictStringValue(fDict, "_description") || this.findDictStringValue(fDict, "description") || "";
|
|
9066
9323
|
}
|
|
9067
|
-
|
|
9068
|
-
description = filename;
|
|
9069
|
-
}
|
|
9070
|
-
const displayName = this.findDictStringValue(fDict, "_displayName") || this.findDictStringValue(fDict, "displayName") || this.findDictStringValue(fDict, "_label") || this.findDictStringValue(fDict, "label") || filename;
|
|
9071
|
-
const path = `resources/${filename}`;
|
|
9324
|
+
const displayName = this.findDictStringValue(fDict, "_displayName") || this.findDictStringValue(fDict, "displayName") || this.findDictStringValue(fDict, "_label") || this.findDictStringValue(fDict, "label") || "";
|
|
9072
9325
|
return {
|
|
9073
9326
|
filename,
|
|
9074
9327
|
displayName,
|
|
9075
9328
|
description,
|
|
9076
|
-
path
|
|
9329
|
+
path: `resources/${filename}`
|
|
9077
9330
|
};
|
|
9078
9331
|
}
|
|
9079
9332
|
/**
|
|
9080
|
-
* Extract single file resource
|
|
9333
|
+
* Extract a single file resource (older formats with no field list).
|
|
9081
9334
|
*/
|
|
9082
9335
|
extractSingleFile(dict) {
|
|
9083
9336
|
const filename = this.extractResourcePath(dict, "fileResource") || this.extractResourcePath(dict, "_fileResource");
|
|
9084
9337
|
if (!filename) return null;
|
|
9085
|
-
const displayName = this.findDictStringValue(dict, "_displayName") || this.findDictStringValue(dict, "displayName") ||
|
|
9086
|
-
const path = `resources/${filename}`;
|
|
9338
|
+
const displayName = this.findDictStringValue(dict, "_displayName") || this.findDictStringValue(dict, "displayName") || "";
|
|
9087
9339
|
return {
|
|
9088
9340
|
filename,
|
|
9089
9341
|
displayName,
|
|
9090
|
-
description:
|
|
9091
|
-
|
|
9092
|
-
path
|
|
9342
|
+
description: "",
|
|
9343
|
+
path: `resources/${filename}`
|
|
9093
9344
|
};
|
|
9094
9345
|
}
|
|
9346
|
+
/**
|
|
9347
|
+
* Escape text for safe insertion into an HTML attribute.
|
|
9348
|
+
*/
|
|
9349
|
+
escapeAttr(value) {
|
|
9350
|
+
return this.escapeHtml(value);
|
|
9351
|
+
}
|
|
9095
9352
|
};
|
|
9096
9353
|
|
|
9097
9354
|
// src/shared/import/legacy-handlers/ImageMagnifierHandler.ts
|
|
@@ -9869,7 +10126,7 @@
|
|
|
9869
10126
|
* @param dict - Dictionary element
|
|
9870
10127
|
* @returns HTML content with updated DataGame div
|
|
9871
10128
|
*/
|
|
9872
|
-
extractHtmlView(dict,
|
|
10129
|
+
extractHtmlView(dict, context) {
|
|
9873
10130
|
if (!dict) return "";
|
|
9874
10131
|
const contents = [];
|
|
9875
10132
|
const children = this.getChildElements(dict);
|
|
@@ -9878,7 +10135,7 @@
|
|
|
9878
10135
|
if (child.tagName === "string" && child.getAttribute("role") === "key" && child.getAttribute("value") === "fields") {
|
|
9879
10136
|
const listEl = children[i + 1];
|
|
9880
10137
|
if (listEl && listEl.tagName === "list") {
|
|
9881
|
-
const fieldInstances =
|
|
10138
|
+
const fieldInstances = resolveFieldInstances(listEl, context?.resolveReference);
|
|
9882
10139
|
for (const fieldInst of fieldInstances) {
|
|
9883
10140
|
const fieldClass = fieldInst.getAttribute("class") || "";
|
|
9884
10141
|
if (fieldClass.includes("TextAreaField") || fieldClass.includes("TextField")) {
|
|
@@ -10370,10 +10627,10 @@
|
|
|
10370
10627
|
ImageGalleryIdevice: "image-gallery",
|
|
10371
10628
|
ImageMagnifierIdevice: "magnifier",
|
|
10372
10629
|
GalleryIdevice: "image-gallery",
|
|
10373
|
-
// File iDevices ->
|
|
10374
|
-
FileAttachIdevice: "
|
|
10375
|
-
FileAttachIdeviceInc: "
|
|
10376
|
-
AttachmentIdevice: "
|
|
10630
|
+
// File iDevices -> dedicated file-attachment iDevice
|
|
10631
|
+
FileAttachIdevice: "file-attachment",
|
|
10632
|
+
FileAttachIdeviceInc: "file-attachment",
|
|
10633
|
+
AttachmentIdevice: "file-attachment",
|
|
10377
10634
|
// External content
|
|
10378
10635
|
ExternalUrlIdevice: "external-website",
|
|
10379
10636
|
GeogebraIdevice: "geogebra-activity",
|
|
@@ -10416,7 +10673,7 @@
|
|
|
10416
10673
|
new ExternalUrlHandler(),
|
|
10417
10674
|
// ExternalUrlIdevice -> external-website
|
|
10418
10675
|
new FileAttachHandler(),
|
|
10419
|
-
// FileAttachIdevice, AttachmentIdevice ->
|
|
10676
|
+
// FileAttachIdevice, AttachmentIdevice -> file-attachment
|
|
10420
10677
|
new ImageMagnifierHandler(),
|
|
10421
10678
|
// ImageMagnifierIdevice -> magnifier
|
|
10422
10679
|
new GeogebraHandler(),
|
|
@@ -10479,6 +10736,12 @@
|
|
|
10479
10736
|
this.xmlContent = "";
|
|
10480
10737
|
this.xmlDoc = null;
|
|
10481
10738
|
this.parentRefMap = /* @__PURE__ */ new Map();
|
|
10739
|
+
/**
|
|
10740
|
+
* Maps an `instance` element's `reference` attribute value to the element itself.
|
|
10741
|
+
* Built once per parsed document to avoid O(N²) full-document scans when resolving
|
|
10742
|
+
* `<reference key="..."/>` lookups (see {@link getInstanceByReference}).
|
|
10743
|
+
*/
|
|
10744
|
+
this.instanceByReferenceMap = /* @__PURE__ */ new Map();
|
|
10482
10745
|
this.projectLanguage = "";
|
|
10483
10746
|
this.logger = logger;
|
|
10484
10747
|
}
|
|
@@ -10716,9 +10979,9 @@
|
|
|
10716
10979
|
let xml = xmlContent;
|
|
10717
10980
|
const protectedPreBlocks = [];
|
|
10718
10981
|
const protectPreBlock = (match) => {
|
|
10719
|
-
const
|
|
10982
|
+
const token2 = `__LEGACY_PRE_BLOCK_${protectedPreBlocks.length}__`;
|
|
10720
10983
|
protectedPreBlocks.push(match);
|
|
10721
|
-
return
|
|
10984
|
+
return token2;
|
|
10722
10985
|
};
|
|
10723
10986
|
xml = xml.replace(/<unicode\b[^>]*>/gi, (tag) => {
|
|
10724
10987
|
return tag.replace(/\bvalue=(['"])([\s\S]*?)\1/i, (_match, quote, value) => {
|
|
@@ -10740,7 +11003,19 @@
|
|
|
10740
11003
|
xml = xml.replace(/\\x([0-9A-Fa-f]{2})/g, (_match, hex) => {
|
|
10741
11004
|
return String.fromCharCode(parseInt(hex, 16));
|
|
10742
11005
|
});
|
|
11006
|
+
const latexBlocks = [];
|
|
11007
|
+
let token;
|
|
11008
|
+
do {
|
|
11009
|
+
token = `\0LTXP${Math.random().toString(36).slice(2)}\0`;
|
|
11010
|
+
} while (xml.includes(token));
|
|
11011
|
+
const latexPattern = /\\\((?:[^\\]|\\.)*?\\\)|\\\[(?:[^\\]|\\.)*?\\\]|\\begin\{[^}]+\}(?:[^\\]|\\.)*?\\end\{[^}]+\}|\$\$(?:[^$]|\\.)*?\$\$|(?<!\\)\$(?!\d+(?:[.,]\d+)?\b)(?:[^$\\]|\\.)*?(?<!\\)\$/g;
|
|
11012
|
+
xml = xml.replace(latexPattern, (match) => {
|
|
11013
|
+
latexBlocks.push(match);
|
|
11014
|
+
return `${token}${latexBlocks.length - 1}${token}`;
|
|
11015
|
+
});
|
|
10743
11016
|
xml = xml.replace(/\\n/g, " ");
|
|
11017
|
+
const tokenPattern = new RegExp(`${token}(\\d+)${token}`, "g");
|
|
11018
|
+
xml = xml.replace(tokenPattern, (_match, i) => latexBlocks[Number(i)]);
|
|
10744
11019
|
return xml;
|
|
10745
11020
|
}
|
|
10746
11021
|
/**
|
|
@@ -10760,6 +11035,7 @@
|
|
|
10760
11035
|
throw new Error(`XML parsing error: ${parseError.textContent}`);
|
|
10761
11036
|
}
|
|
10762
11037
|
this.buildParentReferenceMap();
|
|
11038
|
+
this.buildInstanceReferenceMap();
|
|
10763
11039
|
const nodes = this.findAllNodes();
|
|
10764
11040
|
this.logger.log(`[LegacyXmlParser] Found ${nodes.length} legacy nodes`);
|
|
10765
11041
|
const meta = this.extractMetadata();
|
|
@@ -10789,6 +11065,45 @@
|
|
|
10789
11065
|
}
|
|
10790
11066
|
this.logger.log(`[LegacyXmlParser] Built parent map with ${this.parentRefMap.size} entries`);
|
|
10791
11067
|
}
|
|
11068
|
+
/**
|
|
11069
|
+
* Build a `reference` -> `instance` element map once for the current document.
|
|
11070
|
+
*
|
|
11071
|
+
* Legacy XML uses `<reference key="N"/>` placeholders that point back to the first
|
|
11072
|
+
* `<instance reference="N">` declared in document order. Previously each placeholder
|
|
11073
|
+
* was resolved with a full-document `getElementsByTagName('instance')` scan, which is
|
|
11074
|
+
* O(N²) across the whole document and could pin the event loop for tens of seconds on
|
|
11075
|
+
* large legacy ELP files with thousands of cross-referencing instances.
|
|
11076
|
+
*
|
|
11077
|
+
* This precomputes the lookup in a single O(N) pass. To preserve the previous
|
|
11078
|
+
* first-match semantics (`getElementsByAttribute(...)[0]`), only the first `instance`
|
|
11079
|
+
* encountered for a given `reference` value is stored.
|
|
11080
|
+
*/
|
|
11081
|
+
buildInstanceReferenceMap() {
|
|
11082
|
+
this.instanceByReferenceMap.clear();
|
|
11083
|
+
if (!this.xmlDoc) return;
|
|
11084
|
+
const instances = this.getElementsByTagName(this.xmlDoc, "instance");
|
|
11085
|
+
for (const inst of instances) {
|
|
11086
|
+
const ref = inst.getAttribute("reference");
|
|
11087
|
+
if (!ref) continue;
|
|
11088
|
+
if (!this.instanceByReferenceMap.has(ref)) {
|
|
11089
|
+
this.instanceByReferenceMap.set(ref, inst);
|
|
11090
|
+
}
|
|
11091
|
+
}
|
|
11092
|
+
this.logger.log(
|
|
11093
|
+
`[LegacyXmlParser] Built instance reference map with ${this.instanceByReferenceMap.size} entries`
|
|
11094
|
+
);
|
|
11095
|
+
}
|
|
11096
|
+
/**
|
|
11097
|
+
* Resolve a `<reference key="..."/>` placeholder to its target `instance` element.
|
|
11098
|
+
*
|
|
11099
|
+
* O(1) replacement for the former
|
|
11100
|
+
* `getElementsByAttribute(this.xmlDoc, 'instance', 'reference', refKey)[0]` scans.
|
|
11101
|
+
* Returns `undefined` when no matching instance exists, mirroring the previous
|
|
11102
|
+
* `[0]`-on-empty-array behavior so callers' missing-reference handling is unchanged.
|
|
11103
|
+
*/
|
|
11104
|
+
getInstanceByReference(refKey) {
|
|
11105
|
+
return this.instanceByReferenceMap.get(refKey);
|
|
11106
|
+
}
|
|
10792
11107
|
/**
|
|
10793
11108
|
* Find value for a key in a dictionary element
|
|
10794
11109
|
*/
|
|
@@ -11332,9 +11647,9 @@
|
|
|
11332
11647
|
"RecomendacionfpdIdevice",
|
|
11333
11648
|
"WikipediaIdevice",
|
|
11334
11649
|
"RssIdevice",
|
|
11335
|
-
"AppletIdevice"
|
|
11336
|
-
|
|
11337
|
-
|
|
11650
|
+
"AppletIdevice"
|
|
11651
|
+
// Note: FileAttachIdevice / AttachmentIdevice are handled by FileAttachHandler,
|
|
11652
|
+
// which maps them to the dedicated 'file-attachment' iDevice (see HandlerRegistry).
|
|
11338
11653
|
];
|
|
11339
11654
|
for (const textType of textBasedIdevices) {
|
|
11340
11655
|
if (className.includes(textType)) {
|
|
@@ -11383,12 +11698,7 @@
|
|
|
11383
11698
|
} else if (child.tagName === "reference") {
|
|
11384
11699
|
const refKey = child.getAttribute("key");
|
|
11385
11700
|
if (refKey && this.xmlDoc) {
|
|
11386
|
-
const referencedInstance = this.
|
|
11387
|
-
this.xmlDoc,
|
|
11388
|
-
"instance",
|
|
11389
|
-
"reference",
|
|
11390
|
-
refKey
|
|
11391
|
-
)[0];
|
|
11701
|
+
const referencedInstance = this.getInstanceByReference(refKey);
|
|
11392
11702
|
if (referencedInstance) {
|
|
11393
11703
|
this.logger.log(`[LegacyXmlParser] Resolved reference key=${refKey} to instance`);
|
|
11394
11704
|
instancesToProcess.push(referencedInstance);
|
|
@@ -11577,7 +11887,10 @@
|
|
|
11577
11887
|
language: this.projectLanguage,
|
|
11578
11888
|
ideviceId: idevice.id,
|
|
11579
11889
|
className,
|
|
11580
|
-
ideviceType: rawIdeviceDir || ideviceType
|
|
11890
|
+
ideviceType: rawIdeviceDir || ideviceType,
|
|
11891
|
+
// Let handlers resolve <reference key="N"> fields to their instance,
|
|
11892
|
+
// so the explicit fields list stays authoritative (issue #2159).
|
|
11893
|
+
resolveReference: (key) => this.getInstanceByReference(key)
|
|
11581
11894
|
};
|
|
11582
11895
|
const handlerProps = handler.extractProperties(dict, idevice.id);
|
|
11583
11896
|
if (handlerProps && Object.keys(handlerProps).length > 0) {
|
|
@@ -11587,7 +11900,8 @@
|
|
|
11587
11900
|
);
|
|
11588
11901
|
}
|
|
11589
11902
|
const handlerHtml = handler.extractHtmlView(dict, handlerContext);
|
|
11590
|
-
|
|
11903
|
+
const isFallbackHandler = typeof handler.isFallback === "function" && handler.isFallback();
|
|
11904
|
+
if (handlerHtml && !(isFallbackHandler && idevice.htmlView)) {
|
|
11591
11905
|
idevice.htmlView = handlerHtml;
|
|
11592
11906
|
this.logger.log(`[LegacyXmlParser] Used handler htmlView (${handlerHtml.length} chars)`);
|
|
11593
11907
|
}
|
|
@@ -11977,27 +12291,7 @@
|
|
|
11977
12291
|
if (child.tagName === "string" && child.getAttribute("role") === "key" && child.getAttribute("value") === "fields") {
|
|
11978
12292
|
const listEl = children[i + 1];
|
|
11979
12293
|
if (listEl && listEl.tagName === "list") {
|
|
11980
|
-
const
|
|
11981
|
-
const fieldInstances = [];
|
|
11982
|
-
for (const fieldChild of directChildren) {
|
|
11983
|
-
if (fieldChild.tagName === "instance") {
|
|
11984
|
-
fieldInstances.push(fieldChild);
|
|
11985
|
-
} else if (fieldChild.tagName === "reference") {
|
|
11986
|
-
const refKey = fieldChild.getAttribute("key");
|
|
11987
|
-
if (refKey && this.xmlDoc) {
|
|
11988
|
-
const referencedInstance = this.getElementsByAttribute(
|
|
11989
|
-
this.xmlDoc,
|
|
11990
|
-
"instance",
|
|
11991
|
-
"reference",
|
|
11992
|
-
refKey
|
|
11993
|
-
)[0];
|
|
11994
|
-
if (referencedInstance) {
|
|
11995
|
-
this.logger.log(`[LegacyXmlParser] Resolved field reference key=${refKey}`);
|
|
11996
|
-
fieldInstances.push(referencedInstance);
|
|
11997
|
-
}
|
|
11998
|
-
}
|
|
11999
|
-
}
|
|
12000
|
-
}
|
|
12294
|
+
const fieldInstances = resolveFieldInstances(listEl, (key) => this.getInstanceByReference(key));
|
|
12001
12295
|
for (const fieldInst of fieldInstances) {
|
|
12002
12296
|
const fieldClass = fieldInst.getAttribute("class") || "";
|
|
12003
12297
|
if (fieldClass.includes("TextAreaField") || fieldClass.includes("TextField")) {
|
|
@@ -12109,12 +12403,7 @@
|
|
|
12109
12403
|
if (valueEl.tagName === "reference") {
|
|
12110
12404
|
const refKey = valueEl.getAttribute("key");
|
|
12111
12405
|
if (refKey && this.xmlDoc) {
|
|
12112
|
-
const referencedInstance = this.
|
|
12113
|
-
this.xmlDoc,
|
|
12114
|
-
"instance",
|
|
12115
|
-
"reference",
|
|
12116
|
-
refKey
|
|
12117
|
-
)[0];
|
|
12406
|
+
const referencedInstance = this.getInstanceByReference(refKey);
|
|
12118
12407
|
if (referencedInstance) {
|
|
12119
12408
|
const refClass = referencedInstance.getAttribute("class") || "";
|
|
12120
12409
|
if (refClass.includes("TextAreaField") || refClass.includes("TextField")) {
|
|
@@ -12310,7 +12599,7 @@
|
|
|
12310
12599
|
throw new Error("generateId: prefix is required");
|
|
12311
12600
|
}
|
|
12312
12601
|
const timestamp = Date.now().toString(36);
|
|
12313
|
-
const random = Math.random().toString(36).substring(2, 11);
|
|
12602
|
+
const random = Math.random().toString(36).substring(2, 11).padEnd(9, "0");
|
|
12314
12603
|
return `${prefix}-${timestamp}-${random}`;
|
|
12315
12604
|
}
|
|
12316
12605
|
|
|
@@ -12327,19 +12616,120 @@
|
|
|
12327
12616
|
}
|
|
12328
12617
|
|
|
12329
12618
|
// src/shared/import/ElpxImporter.ts
|
|
12619
|
+
function inspectZipArchive(buffer, _label = "ELP/ELPX archive") {
|
|
12620
|
+
const entries = [];
|
|
12621
|
+
let totalBytes = 0;
|
|
12622
|
+
let largestEntry = null;
|
|
12623
|
+
unzipSync(buffer, {
|
|
12624
|
+
filter: (file) => {
|
|
12625
|
+
const size = file.originalSize;
|
|
12626
|
+
const entry = { name: file.name, size };
|
|
12627
|
+
entries.push(entry);
|
|
12628
|
+
totalBytes += size;
|
|
12629
|
+
if (largestEntry === null || size > largestEntry.size) {
|
|
12630
|
+
largestEntry = entry;
|
|
12631
|
+
}
|
|
12632
|
+
return false;
|
|
12633
|
+
}
|
|
12634
|
+
});
|
|
12635
|
+
return { entries, totalBytes, entryCount: entries.length, largestEntry };
|
|
12636
|
+
}
|
|
12330
12637
|
var ElpxImporter = class {
|
|
12331
12638
|
/**
|
|
12332
12639
|
* Create a new ElpxImporter
|
|
12333
12640
|
* @param ydoc - Yjs document to populate
|
|
12334
12641
|
* @param assetHandler - Asset handler for storing assets (optional)
|
|
12335
12642
|
* @param logger - Logger for debug output (optional)
|
|
12643
|
+
* @param zipLimits - ZIP-bomb decompression limits (optional, sensible defaults)
|
|
12336
12644
|
*/
|
|
12337
|
-
constructor(ydoc, assetHandler = null, logger = defaultLogger) {
|
|
12645
|
+
constructor(ydoc, assetHandler = null, logger = defaultLogger, zipLimits = {}) {
|
|
12338
12646
|
this.assetMap = /* @__PURE__ */ new Map();
|
|
12339
12647
|
this.onProgress = null;
|
|
12648
|
+
/** Activities whose asset references the package could not satisfy (#2223). */
|
|
12649
|
+
this.unresolvedAssets = [];
|
|
12340
12650
|
this.ydoc = ydoc;
|
|
12341
12651
|
this.assetHandler = assetHandler;
|
|
12342
12652
|
this.logger = logger;
|
|
12653
|
+
this.zipLimits = validateZipLimits({ ...DEFAULT_ZIP_LIMITS, ...zipLimits });
|
|
12654
|
+
}
|
|
12655
|
+
/**
|
|
12656
|
+
* Decompress a ZIP buffer with hard limits enforced BEFORE inflation.
|
|
12657
|
+
*
|
|
12658
|
+
* fflate's `filter` callback receives each entry's `originalSize` (read from
|
|
12659
|
+
* the ZIP central directory) and runs before that entry is decompressed.
|
|
12660
|
+
* We use it to reject the archive the moment a per-entry, cumulative, or
|
|
12661
|
+
* entry-count cap would be exceeded, throwing {@link ZipLimitError}. This
|
|
12662
|
+
* guarantees the offending bytes are never materialised in memory, so a
|
|
12663
|
+
* zip bomb cannot OOM the process.
|
|
12664
|
+
*
|
|
12665
|
+
* KNOWN LIMITATION (intentional, documented): `originalSize` is the
|
|
12666
|
+
* *declared* uncompressed size in the central directory, i.e. it is
|
|
12667
|
+
* attacker-controlled metadata, not a measured value. A crafted archive can
|
|
12668
|
+
* understate `originalSize` so an entry passes the pre-inflation filter and
|
|
12669
|
+
* then inflates to more bytes than declared. fflate decompresses
|
|
12670
|
+
* synchronously and does not stream/abort mid-inflation through this filter,
|
|
12671
|
+
* so we cannot enforce the cap against the *actual* inflated length here. We
|
|
12672
|
+
* accept this trade-off: the declared-size check stops the common
|
|
12673
|
+
* over-declared zip bomb cheaply and without inflation, and a single
|
|
12674
|
+
* entry's actual overrun is bounded in practice by available memory; full
|
|
12675
|
+
* defence would require a streaming inflater that aborts on byte count.
|
|
12676
|
+
* `maxEntryBytes` therefore caps *declared* per-entry size, not guaranteed
|
|
12677
|
+
* inflated size.
|
|
12678
|
+
*
|
|
12679
|
+
* @param buffer - Raw ZIP bytes
|
|
12680
|
+
* @param label - Human-readable archive label for error messages
|
|
12681
|
+
* @returns Map of entry path -> decompressed bytes
|
|
12682
|
+
*/
|
|
12683
|
+
safeUnzip(buffer, label) {
|
|
12684
|
+
const { maxTotalBytes, maxEntryBytes, maxEntries } = this.zipLimits;
|
|
12685
|
+
let cumulativeBytes = 0;
|
|
12686
|
+
let entryCount = 0;
|
|
12687
|
+
return unzipSync(buffer, {
|
|
12688
|
+
filter: (file) => {
|
|
12689
|
+
entryCount++;
|
|
12690
|
+
if (entryCount > maxEntries) {
|
|
12691
|
+
throw entryCountError(label, entryCount, maxEntries);
|
|
12692
|
+
}
|
|
12693
|
+
const entrySize = file.originalSize;
|
|
12694
|
+
if (entrySize > maxEntryBytes) {
|
|
12695
|
+
throw entrySizeError(label, file.name, entrySize, maxEntryBytes);
|
|
12696
|
+
}
|
|
12697
|
+
cumulativeBytes += entrySize;
|
|
12698
|
+
if (cumulativeBytes > maxTotalBytes) {
|
|
12699
|
+
throw totalSizeError(label, cumulativeBytes, maxTotalBytes);
|
|
12700
|
+
}
|
|
12701
|
+
return true;
|
|
12702
|
+
}
|
|
12703
|
+
});
|
|
12704
|
+
}
|
|
12705
|
+
/**
|
|
12706
|
+
* Validate an already-decompressed entry map against the same limits as
|
|
12707
|
+
* {@link safeUnzip}. Used by `importFromZipContents`, whose caller provides
|
|
12708
|
+
* the contents already inflated: we cannot prevent the original inflation,
|
|
12709
|
+
* but we refuse to keep processing (asset writes, Y.Doc population) an
|
|
12710
|
+
* archive whose materialised payload exceeds the configured caps, so the
|
|
12711
|
+
* server-side path stays bounded.
|
|
12712
|
+
*
|
|
12713
|
+
* @param zipContents - Already-extracted entry map
|
|
12714
|
+
* @param label - Human-readable archive label for error messages
|
|
12715
|
+
*/
|
|
12716
|
+
assertZipContentsWithinLimits(zipContents, label) {
|
|
12717
|
+
const { maxTotalBytes, maxEntryBytes, maxEntries } = this.zipLimits;
|
|
12718
|
+
const entries = Object.entries(zipContents);
|
|
12719
|
+
if (entries.length > maxEntries) {
|
|
12720
|
+
throw entryCountError(label, entries.length, maxEntries);
|
|
12721
|
+
}
|
|
12722
|
+
let cumulativeBytes = 0;
|
|
12723
|
+
for (const [name, data] of entries) {
|
|
12724
|
+
const entrySize = data.length;
|
|
12725
|
+
if (entrySize > maxEntryBytes) {
|
|
12726
|
+
throw entrySizeError(label, name, entrySize, maxEntryBytes);
|
|
12727
|
+
}
|
|
12728
|
+
cumulativeBytes += entrySize;
|
|
12729
|
+
if (cumulativeBytes > maxTotalBytes) {
|
|
12730
|
+
throw totalSizeError(label, cumulativeBytes, maxTotalBytes);
|
|
12731
|
+
}
|
|
12732
|
+
}
|
|
12343
12733
|
}
|
|
12344
12734
|
// =========================================================================
|
|
12345
12735
|
// DOM Query Helpers (compatible with @xmldom/xmldom)
|
|
@@ -12423,7 +12813,7 @@
|
|
|
12423
12813
|
}
|
|
12424
12814
|
this.logger.log("[ElpxImporter] Starting import from buffer");
|
|
12425
12815
|
this.reportProgress("decompress", 0, "Decompressing...");
|
|
12426
|
-
const zip =
|
|
12816
|
+
const zip = this.safeUnzip(buffer, "ELP/ELPX archive");
|
|
12427
12817
|
this.reportProgress("decompress", 10, "File decompressed");
|
|
12428
12818
|
let workingZip = this.unwrapSingleTopLevelDirectory(zip);
|
|
12429
12819
|
if (!workingZip["content.xml"] && !workingZip["contentv3.xml"]) {
|
|
@@ -12433,7 +12823,7 @@
|
|
|
12433
12823
|
if (elpFiles.length === 1) {
|
|
12434
12824
|
this.logger.log(`[ElpxImporter] Found nested ELP file: ${elpFiles[0]}, extracting...`);
|
|
12435
12825
|
const nestedElpData = workingZip[elpFiles[0]];
|
|
12436
|
-
workingZip =
|
|
12826
|
+
workingZip = this.safeUnzip(nestedElpData, `nested ELP file '${elpFiles[0]}'`);
|
|
12437
12827
|
} else if (elpFiles.length > 1) {
|
|
12438
12828
|
throw new Error("ZIP contains multiple ELP files. Please extract and open one at a time.");
|
|
12439
12829
|
}
|
|
@@ -12496,6 +12886,7 @@
|
|
|
12496
12886
|
this.onProgress = onProgress;
|
|
12497
12887
|
}
|
|
12498
12888
|
this.logger.log("[ElpxImporter] Starting import from zip contents");
|
|
12889
|
+
this.assertZipContentsWithinLimits(zipContents, "extracted ELP contents");
|
|
12499
12890
|
this.reportProgress("decompress", 10, "Files ready");
|
|
12500
12891
|
zipContents = this.unwrapSingleTopLevelDirectory(zipContents);
|
|
12501
12892
|
let contentFile = zipContents["content.xml"];
|
|
@@ -12544,12 +12935,25 @@
|
|
|
12544
12935
|
const stats = await this.importStructure(xmlDoc, zipContents, { clearExisting, parentId });
|
|
12545
12936
|
return stats;
|
|
12546
12937
|
}
|
|
12938
|
+
/**
|
|
12939
|
+
* Note that an activity still carries asset references the package could
|
|
12940
|
+
* not satisfy, so the caller can tell the author which files are missing
|
|
12941
|
+
* instead of leaving them to read a raw placeholder in a form field (#2223).
|
|
12942
|
+
*
|
|
12943
|
+
* @param componentId - id of the activity the text belongs to
|
|
12944
|
+
* @param ideviceType - iDevice type, so the notice can name the activity
|
|
12945
|
+
* @param text - HTML or serialized properties, after asset conversion ran
|
|
12946
|
+
*/
|
|
12947
|
+
recordUnresolvedAssets(componentId, ideviceType, text) {
|
|
12948
|
+
addUnresolvedAssetRefs(this.unresolvedAssets, componentId, ideviceType, text);
|
|
12949
|
+
}
|
|
12547
12950
|
/**
|
|
12548
12951
|
* Import document structure from parsed XML
|
|
12549
12952
|
*/
|
|
12550
12953
|
async importStructure(xmlDoc, zip, options = {}) {
|
|
12551
12954
|
const { clearExisting = true, parentId = null } = options;
|
|
12552
12955
|
const stats = { pages: 0, blocks: 0, components: 0, assets: 0 };
|
|
12956
|
+
this.unresolvedAssets = [];
|
|
12553
12957
|
this.reportProgress("assets", 10, "Extracting assets...");
|
|
12554
12958
|
stats.assets = await this.importAssets(zip);
|
|
12555
12959
|
this.reportProgress("assets", 50, "Assets extracted");
|
|
@@ -12667,6 +13071,7 @@
|
|
|
12667
13071
|
this.reportProgress("precache", 100, "Import complete");
|
|
12668
13072
|
stats.theme = metadataValues.theme || null;
|
|
12669
13073
|
stats.zipContents = zip;
|
|
13074
|
+
stats.missingAssets = this.unresolvedAssets;
|
|
12670
13075
|
const { zipContents: _zip, ...statsWithoutZip } = stats;
|
|
12671
13076
|
this.logger.log("[ElpxImporter] Import complete:", statsWithoutZip);
|
|
12672
13077
|
return stats;
|
|
@@ -12677,6 +13082,7 @@
|
|
|
12677
13082
|
async importLegacyStructure(parsedData, zip, options = {}) {
|
|
12678
13083
|
const { clearExisting = true, parentId = null } = options;
|
|
12679
13084
|
const stats = { pages: 0, blocks: 0, components: 0, assets: 0 };
|
|
13085
|
+
this.unresolvedAssets = [];
|
|
12680
13086
|
this.reportProgress("assets", 10, "Extracting assets...");
|
|
12681
13087
|
stats.assets = await this.importAssets(zip);
|
|
12682
13088
|
this.reportProgress("assets", 50, "Assets extracted");
|
|
@@ -12736,6 +13142,7 @@
|
|
|
12736
13142
|
}
|
|
12737
13143
|
this.reportProgress("precache", 100, "Import complete");
|
|
12738
13144
|
stats.zipContents = zip;
|
|
13145
|
+
stats.missingAssets = this.unresolvedAssets;
|
|
12739
13146
|
const { zipContents: _zipLegacy, ...legacyStatsWithoutZip } = stats;
|
|
12740
13147
|
this.logger.log("[ElpxImporter] Legacy import complete:", legacyStatsWithoutZip);
|
|
12741
13148
|
return stats;
|
|
@@ -12816,6 +13223,7 @@
|
|
|
12816
13223
|
this.logger.warn(`[ElpxImporter] Error converting asset paths for ${legacyIdevice.id}:`, convErr);
|
|
12817
13224
|
}
|
|
12818
13225
|
}
|
|
13226
|
+
this.recordUnresolvedAssets(legacyIdevice.id, legacyIdevice.type || "unknown", htmlView);
|
|
12819
13227
|
let properties = legacyIdevice.properties || {};
|
|
12820
13228
|
if (legacyIdevice.type === "text" && htmlView) {
|
|
12821
13229
|
properties = {
|
|
@@ -12823,6 +13231,19 @@
|
|
|
12823
13231
|
textTextarea: htmlView
|
|
12824
13232
|
};
|
|
12825
13233
|
}
|
|
13234
|
+
if (legacyIdevice.type === "scrambled-list" && htmlView) {
|
|
13235
|
+
const extractedProperties = this.extractScrambledListProperties(htmlView);
|
|
13236
|
+
if (extractedProperties) {
|
|
13237
|
+
const previousOptions = properties.options;
|
|
13238
|
+
properties = {
|
|
13239
|
+
...extractedProperties,
|
|
13240
|
+
...properties
|
|
13241
|
+
};
|
|
13242
|
+
if (!Array.isArray(previousOptions) || previousOptions.length === 0) {
|
|
13243
|
+
properties.options = extractedProperties.options;
|
|
13244
|
+
}
|
|
13245
|
+
}
|
|
13246
|
+
}
|
|
12826
13247
|
const componentData = {
|
|
12827
13248
|
id: componentId,
|
|
12828
13249
|
ideviceId: componentId,
|
|
@@ -12899,6 +13320,69 @@
|
|
|
12899
13320
|
if (!html) return false;
|
|
12900
13321
|
return html.includes("feedbacktooglebutton") || html.includes("feedbackbutton") || html.includes("iDevice_buttons feedback-button") || html.includes('class="feedback-button');
|
|
12901
13322
|
}
|
|
13323
|
+
extractScrambledListProperties(htmlView) {
|
|
13324
|
+
if (!htmlView || !htmlView.includes("exe-sortableList")) return null;
|
|
13325
|
+
const doc = new import_xmldom2.DOMParser().parseFromString(`<div>${htmlView}</div>`, "text/html");
|
|
13326
|
+
const activity = this.getFirstElementByClass(doc, "exe-sortableList");
|
|
13327
|
+
if (!activity) return null;
|
|
13328
|
+
const optionsList = this.getFirstElementByClass(activity, "exe-sortableList-list") || this.getElements(activity, "ul")[0];
|
|
13329
|
+
const options = optionsList ? this.getDirectChildElements(optionsList, "li").map((item) => this.getElementInnerHtml(item) || (item.textContent || "").trim()).filter((option) => option !== "") : [];
|
|
13330
|
+
if (options.length === 0) return null;
|
|
13331
|
+
const textAfter = this.getElementInnerHtmlByClass(activity, "exe-sortableList-textAfter");
|
|
13332
|
+
return {
|
|
13333
|
+
typeGame: "ScrambledList",
|
|
13334
|
+
instructions: this.getElementInnerHtmlByClass(activity, "exe-sortableList-instructions"),
|
|
13335
|
+
textAfter,
|
|
13336
|
+
afterElement: textAfter ? `<div class="exe-sortableList-textAfter">${textAfter}</div>` : "",
|
|
13337
|
+
options,
|
|
13338
|
+
time: 0,
|
|
13339
|
+
buttonText: this.getElementTextByClass(activity, "exe-sortableList-buttonText") || "Check",
|
|
13340
|
+
rightText: this.getElementTextByClass(activity, "exe-sortableList-rightText") || "Right!",
|
|
13341
|
+
wrongText: this.getElementTextByClass(activity, "exe-sortableList-wrongText") || "Sorry, that's incorrect... The right answer is:",
|
|
13342
|
+
isScorm: 0,
|
|
13343
|
+
textButtonScorm: "Save score",
|
|
13344
|
+
repeatActivity: false,
|
|
13345
|
+
weighted: 100,
|
|
13346
|
+
showSolutions: true,
|
|
13347
|
+
attemptsNumber: 1
|
|
13348
|
+
};
|
|
13349
|
+
}
|
|
13350
|
+
getFirstElementByClass(parent, className) {
|
|
13351
|
+
return this.getElementsByClass(parent, className)[0] || null;
|
|
13352
|
+
}
|
|
13353
|
+
getElementsByClass(parent, className) {
|
|
13354
|
+
return this.getElements(parent, "*").filter((element) => this.elementHasClass(element, className));
|
|
13355
|
+
}
|
|
13356
|
+
elementHasClass(element, className) {
|
|
13357
|
+
return ` ${element.getAttribute("class") || ""} `.includes(` ${className} `);
|
|
13358
|
+
}
|
|
13359
|
+
getDirectChildElements(parent, tagName) {
|
|
13360
|
+
const normalizedTag = tagName.toLowerCase();
|
|
13361
|
+
return Array.from(parent.childNodes || []).filter((child) => {
|
|
13362
|
+
if (child.nodeType !== 1) return false;
|
|
13363
|
+
return (child.tagName || "").toLowerCase() === normalizedTag;
|
|
13364
|
+
});
|
|
13365
|
+
}
|
|
13366
|
+
getElementTextByClass(parent, className) {
|
|
13367
|
+
const element = this.getFirstElementByClass(parent, className);
|
|
13368
|
+
return (element?.textContent || "").trim();
|
|
13369
|
+
}
|
|
13370
|
+
getElementInnerHtmlByClass(parent, className) {
|
|
13371
|
+
const element = this.getFirstElementByClass(parent, className);
|
|
13372
|
+
return element ? this.getElementInnerHtml(element) : "";
|
|
13373
|
+
}
|
|
13374
|
+
getElementInnerHtml(element) {
|
|
13375
|
+
const elementWithInnerHtml = element;
|
|
13376
|
+
if (typeof elementWithInnerHtml.innerHTML === "string") {
|
|
13377
|
+
return this.stripXhtmlNamespaceAttributes(elementWithInnerHtml.innerHTML).trim();
|
|
13378
|
+
}
|
|
13379
|
+
const serializer = new import_xmldom2.XMLSerializer();
|
|
13380
|
+
const html = Array.from(element.childNodes || []).map((child) => serializer.serializeToString(child)).join("");
|
|
13381
|
+
return this.stripXhtmlNamespaceAttributes(html).trim();
|
|
13382
|
+
}
|
|
13383
|
+
stripXhtmlNamespaceAttributes(html) {
|
|
13384
|
+
return html.replace(/\s+xmlns="http:\/\/www\.w3\.org\/1999\/xhtml"/g, "");
|
|
13385
|
+
}
|
|
12902
13386
|
/**
|
|
12903
13387
|
* Extract screenshot.png from archive root and return as data URL, or undefined.
|
|
12904
13388
|
*/
|
|
@@ -13156,6 +13640,7 @@
|
|
|
13156
13640
|
}
|
|
13157
13641
|
}
|
|
13158
13642
|
compData.htmlView = typeof htmlContent === "string" ? htmlContent : "";
|
|
13643
|
+
this.recordUnresolvedAssets(componentId, ideviceType, compData.htmlView);
|
|
13159
13644
|
}
|
|
13160
13645
|
const jsonPropsNode = this.getElement(compNode, "jsonProperties");
|
|
13161
13646
|
if (jsonPropsNode) {
|
|
@@ -13198,6 +13683,7 @@
|
|
|
13198
13683
|
props.ideviceId = componentId;
|
|
13199
13684
|
}
|
|
13200
13685
|
compData.properties = props;
|
|
13686
|
+
this.recordUnresolvedAssets(componentId, ideviceType, JSON.stringify(props));
|
|
13201
13687
|
} catch (e) {
|
|
13202
13688
|
this.logger.warn(`[ElpxImporter] Failed to process JSON properties for ${componentId}:`, e);
|
|
13203
13689
|
}
|
|
@@ -13781,15 +14267,15 @@
|
|
|
13781
14267
|
return obj;
|
|
13782
14268
|
}
|
|
13783
14269
|
if (typeof obj === "string") {
|
|
13784
|
-
if (obj.includes("{{context_path}}") && this.assetHandler) {
|
|
13785
|
-
return this.assetHandler.convertContextPathToAssetRefs(obj, this.assetMap);
|
|
13786
|
-
}
|
|
13787
14270
|
if (obj.startsWith("resources/") && this.assetMap.size > 0) {
|
|
13788
14271
|
const assetUrl = this.findAssetUrlForPath(obj);
|
|
13789
14272
|
if (assetUrl) {
|
|
13790
14273
|
return assetUrl;
|
|
13791
14274
|
}
|
|
13792
14275
|
}
|
|
14276
|
+
if (this.assetHandler && (obj.includes("{{context_path}}") || obj.includes("resources/"))) {
|
|
14277
|
+
return this.assetHandler.convertContextPathToAssetRefs(obj, this.assetMap);
|
|
14278
|
+
}
|
|
13793
14279
|
return obj;
|
|
13794
14280
|
}
|
|
13795
14281
|
if (Array.isArray(obj)) {
|
|
@@ -13963,33 +14449,60 @@
|
|
|
13963
14449
|
* @param assetManager - AssetManager instance (optional)
|
|
13964
14450
|
*/
|
|
13965
14451
|
constructor(documentManager, assetManager = null) {
|
|
13966
|
-
this.importer = null;
|
|
13967
14452
|
this.manager = documentManager;
|
|
13968
14453
|
this.assetManager = assetManager;
|
|
13969
14454
|
this.logger = getBrowserLogger2();
|
|
13970
14455
|
}
|
|
13971
14456
|
/**
|
|
13972
|
-
*
|
|
14457
|
+
* Build the underlying core ElpxImporter with the resolved limits.
|
|
14458
|
+
*
|
|
14459
|
+
* A fresh instance is created for every import so a previously-used runtime
|
|
14460
|
+
* policy can never be retained (a cached importer bakes its limits in at
|
|
14461
|
+
* construction). Imports happen once per file open, so this has no
|
|
14462
|
+
* meaningful cost.
|
|
13973
14463
|
*/
|
|
13974
|
-
|
|
13975
|
-
|
|
13976
|
-
|
|
13977
|
-
|
|
13978
|
-
this.importer = new ElpxImporter(ydoc, assetHandler, this.logger);
|
|
13979
|
-
}
|
|
13980
|
-
return this.importer;
|
|
14464
|
+
buildImporter(limits) {
|
|
14465
|
+
const ydoc = this.manager.getDoc();
|
|
14466
|
+
const assetHandler = this.assetManager ? createBrowserAssetHandler(this.assetManager) : null;
|
|
14467
|
+
return new ElpxImporter(ydoc, assetHandler, this.logger, limits);
|
|
13981
14468
|
}
|
|
13982
14469
|
/**
|
|
13983
14470
|
* Import an .elpx file (browser File API)
|
|
13984
14471
|
* Compatible with the old ElpxImporter.importFromFile() API
|
|
13985
14472
|
*
|
|
14473
|
+
* The archive is inspected (central directory only, no inflation) and
|
|
14474
|
+
* validated against the resolved limits BEFORE anything is decompressed or
|
|
14475
|
+
* any project state is mutated. When the largest entry is in the controlled
|
|
14476
|
+
* range (above `confirmEntryThreshold` but within the hard limit) and a
|
|
14477
|
+
* confirmation callback is supplied, the user is asked before proceeding.
|
|
14478
|
+
*
|
|
13986
14479
|
* @param file - The .elpx file to import
|
|
13987
|
-
* @param options - Import options
|
|
14480
|
+
* @param options - Import options (see {@link ImportFromFileOptions})
|
|
13988
14481
|
* @returns Import statistics
|
|
13989
14482
|
*/
|
|
13990
14483
|
async importFromFile(file, options = {}) {
|
|
13991
14484
|
const { clearExisting = true, parentId = null, onProgress = null, clearIndexedDB = false } = options;
|
|
13992
14485
|
this.logger.log(`[BrowserElpxImporter] Importing ${file.name}...`);
|
|
14486
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
14487
|
+
const buffer = new Uint8Array(arrayBuffer);
|
|
14488
|
+
const label = "ELP/ELPX archive";
|
|
14489
|
+
const limits = validateZipLimits({ ...CONSERVATIVE_ZIP_LIMITS, ...options.zipLimits ?? {} });
|
|
14490
|
+
const inspection = inspectZipArchive(buffer, label);
|
|
14491
|
+
assertInspectionWithinLimits(inspection, limits, label);
|
|
14492
|
+
const confirmThreshold = options.confirmEntryThreshold ?? limits.maxEntryBytes;
|
|
14493
|
+
if (inspection.largestEntry && inspection.largestEntry.size > confirmThreshold && typeof options.onConfirmLargeEntry === "function") {
|
|
14494
|
+
const confirmed = await options.onConfirmLargeEntry({
|
|
14495
|
+
entryName: inspection.largestEntry.name,
|
|
14496
|
+
entryBytes: inspection.largestEntry.size,
|
|
14497
|
+
totalBytes: inspection.totalBytes,
|
|
14498
|
+
entryCount: inspection.entryCount,
|
|
14499
|
+
confirmThreshold,
|
|
14500
|
+
hardLimitBytes: limits.maxEntryBytes
|
|
14501
|
+
});
|
|
14502
|
+
if (!confirmed) {
|
|
14503
|
+
throw new ImportCancelledError("Large ELPX import cancelled by user");
|
|
14504
|
+
}
|
|
14505
|
+
}
|
|
13993
14506
|
if (clearIndexedDB && this.assetManager && "projectId" in this.manager) {
|
|
13994
14507
|
const dbName = `exelearning-project-${this.manager.projectId}`;
|
|
13995
14508
|
this.logger.log(`[BrowserElpxImporter] Clearing IndexedDB: ${dbName}`);
|
|
@@ -14004,15 +14517,29 @@
|
|
|
14004
14517
|
console.warn("[BrowserElpxImporter] Failed to clear IndexedDB:", e);
|
|
14005
14518
|
}
|
|
14006
14519
|
}
|
|
14007
|
-
|
|
14008
|
-
|
|
14009
|
-
|
|
14520
|
+
if (typeof options.beforeImport === "function") {
|
|
14521
|
+
await options.beforeImport();
|
|
14522
|
+
}
|
|
14523
|
+
const importer = this.buildImporter(limits);
|
|
14010
14524
|
return importer.importFromBuffer(buffer, { clearExisting, parentId, onProgress });
|
|
14011
14525
|
}
|
|
14012
14526
|
};
|
|
14013
14527
|
function createBrowserImporter(documentManager, assetManager = null) {
|
|
14014
14528
|
return new BrowserElpxImporter(documentManager, assetManager);
|
|
14015
14529
|
}
|
|
14530
|
+
var importPolicyNamespace = {
|
|
14531
|
+
CONSERVATIVE_ZIP_LIMITS,
|
|
14532
|
+
DESKTOP_ZIP_LIMITS,
|
|
14533
|
+
DESKTOP_CONFIRM_ENTRY_BYTES,
|
|
14534
|
+
getZipLimitsForRuntime,
|
|
14535
|
+
validateZipLimits,
|
|
14536
|
+
inspectZipArchive,
|
|
14537
|
+
assertInspectionWithinLimits,
|
|
14538
|
+
getDesktopExportCompatibility,
|
|
14539
|
+
formatBytes,
|
|
14540
|
+
ZipLimitError,
|
|
14541
|
+
ImportCancelledError
|
|
14542
|
+
};
|
|
14016
14543
|
if (typeof window !== "undefined") {
|
|
14017
14544
|
window.LegacyHandlerRegistry = LegacyHandlerRegistry;
|
|
14018
14545
|
window.LEGACY_TYPE_MAP = LEGACY_TYPE_MAP;
|
|
@@ -14021,6 +14548,7 @@
|
|
|
14021
14548
|
window.ElpxImporterCore = ElpxImporter;
|
|
14022
14549
|
window.BrowserAssetHandler = BrowserAssetHandler;
|
|
14023
14550
|
window.createBrowserImporter = createBrowserImporter;
|
|
14551
|
+
window.ExeImportPolicy = importPolicyNamespace;
|
|
14024
14552
|
const windowExports = {
|
|
14025
14553
|
// ElpxImporter
|
|
14026
14554
|
ElpxImporter: BrowserElpxImporter,
|
|
@@ -14028,6 +14556,8 @@
|
|
|
14028
14556
|
BrowserAssetHandler,
|
|
14029
14557
|
createBrowserImporter,
|
|
14030
14558
|
createBrowserAssetHandler,
|
|
14559
|
+
// Import policy (single source of truth for limits + export warning)
|
|
14560
|
+
importPolicy: importPolicyNamespace,
|
|
14031
14561
|
// Registry
|
|
14032
14562
|
LegacyHandlerRegistry,
|
|
14033
14563
|
LEGACY_TYPE_MAP,
|