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.
Files changed (32) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +79 -0
  3. package/dist/cli/app/public/exelearning/app/common/common_i18n.js +4 -1
  4. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.ca.js +4 -1
  5. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.de.js +4 -1
  6. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.en.js +4 -1
  7. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.eo.js +4 -1
  8. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.es.js +4 -1
  9. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.eu.js +4 -1
  10. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.gl.js +4 -1
  11. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.it.js +4 -1
  12. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.pt.js +4 -1
  13. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.ro.js +4 -1
  14. package/dist/cli/app/public/exelearning/app/common/i18n/common_i18n.va.js +4 -1
  15. package/dist/cli/app/public/exelearning/bundles/common.zip +0 -0
  16. package/dist/cli/app/public/exelearning/bundles/content-css.zip +0 -0
  17. package/dist/cli/app/public/exelearning/bundles/idevices.zip +0 -0
  18. package/dist/cli/app/public/exelearning/bundles/libs.zip +0 -0
  19. package/dist/cli/app/public/exelearning/bundles/manifest.json +76 -75
  20. package/dist/cli/app/public/exelearning/bundles/themes/base.zip +0 -0
  21. package/dist/cli/app/public/exelearning/bundles/themes/flux.zip +0 -0
  22. package/dist/cli/app/public/exelearning/bundles/themes/neo.zip +0 -0
  23. package/dist/cli/app/public/exelearning/bundles/themes/nova.zip +0 -0
  24. package/dist/cli/app/public/exelearning/bundles/themes/universal.zip +0 -0
  25. package/dist/cli/app/public/exelearning/bundles/themes/zen.zip +0 -0
  26. package/dist/cli/app/public/exelearning/exporters.bundle.js +7250 -353
  27. package/dist/cli/app/public/exelearning/importers.bundle.js +693 -163
  28. package/dist/cli/app/public/exelearning/runtime-source.json +6 -6
  29. package/dist/cli/app/public/info/cli.html +105 -8
  30. package/dist/cli/cli/execonvert.js +47 -7
  31. package/dist/cli/cli/updates.js +315 -0
  32. 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 XMLSerializer() {
2711
+ function XMLSerializer2() {
2712
2712
  }
2713
- XMLSerializer.prototype.serializeToString = function(node, options) {
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 = 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 decoded = content.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\r(?![a-zA-Z])/g, "\r");
7329
- return decoded;
7502
+ const decodedContent = content.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'").replace(/&nbsp;/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 = this.getDirectChildrenByTagName(listEl, "instance");
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 in the dictionary
7627
+ * Extract content from any TextAreaField or TextField reachable within this
7628
+ * iDevice's own object subtree (last-resort fallback).
7435
7629
  *
7436
- * @param dict - Dictionary element
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
- const instances = this.getDirectChildrenByTagName(dict, "instance");
7441
- for (const inst of instances) {
7442
- const className = inst.getAttribute("class") || "";
7443
- if (className.includes("TextAreaField") || className.includes("TextField")) {
7444
- const content = this.extractTextAreaFieldContent(inst);
7445
- if (content) {
7446
- return content;
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, _context) {
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, "_encodedContent") || this.findDictStringValue(qDict, "content_w_resourcePaths") || "";
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
- * Get the target modern iDevice type
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 "text";
9160
+ return "file-attachment";
8934
9161
  }
8935
9162
  /**
8936
- * Extract HTML content with instructions (introHTML) + file links
9163
+ * Build an immediate static view for newly imported legacy components.
8937
9164
  *
8938
- * Matches Symfony OdeOldXmlFileAttachIdevice.php format:
8939
- * - First: introHTML content (instructions)
8940
- * - Then: <p><a href="path" target="_blank">description</a></p> for each file
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 parts = [];
8945
- const introHtml = this.extractIntroHtml(dict);
8946
- if (introHtml) {
8947
- parts.push(introHtml);
8948
- }
8949
- const files = this.extractFiles(dict);
8950
- if (files.length > 0) {
8951
- const fileLinks = files.map((file) => {
8952
- const linkText = file.description || file.displayName || file.filename;
8953
- return `<p><a href="${file.path}" target="_blank" download="${file.filename}">${linkText}</a></p>`;
8954
- }).join("");
8955
- parts.push(fileLinks);
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 attach iDevice
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
- * Extract introHTML content (instructions text)
8967
- *
8968
- * Legacy structure:
8969
- * <string role="key" value="introHTML"/>
8970
- * <instance class="exe.engine.field.TextAreaField">
8971
- * <dictionary>
8972
- * <string role="key" value="content_w_resourcePaths"/>
8973
- * <unicode value="<p>estas son las instrucciones</p>"/>
8974
- * </dictionary>
8975
- * </instance>
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
- * @param dict - Dictionary element of the iDevice
8978
- * @returns HTML content from introHTML
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
- * Extract properties for text iDevice
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
- extractProperties(dict, _ideviceId) {
8991
- const htmlView = this.extractHtmlView(dict);
8992
- if (htmlView) {
8993
- return { textTextarea: htmlView };
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
- if (!description) {
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") || filename;
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: filename,
9091
- // Use filename as description (link text)
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, _context) {
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 = this.getChildElements(listEl).filter((el) => el.tagName === "instance");
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 -> text with links
10374
- FileAttachIdevice: "text",
10375
- FileAttachIdeviceInc: "text",
10376
- AttachmentIdevice: "text",
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 -> text (with file links)
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 token = `__LEGACY_PRE_BLOCK_${protectedPreBlocks.length}__`;
10982
+ const token2 = `__LEGACY_PRE_BLOCK_${protectedPreBlocks.length}__`;
10720
10983
  protectedPreBlocks.push(match);
10721
- return token;
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, "&#10;");
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
- "FileAttachIdevice",
11337
- "AttachmentIdevice"
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.getElementsByAttribute(
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
- if (handlerHtml) {
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 directChildren = Array.from(listEl.childNodes).filter((n) => n.nodeType === 1);
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.getElementsByAttribute(
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 = unzipSync(buffer);
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 = unzipSync(nestedElpData);
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
- * Initialize the underlying ElpxImporter with Y.Doc
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
- getImporter() {
13975
- if (!this.importer) {
13976
- const ydoc = this.manager.getDoc();
13977
- const assetHandler = this.assetManager ? createBrowserAssetHandler(this.assetManager) : null;
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
- const arrayBuffer = await file.arrayBuffer();
14008
- const buffer = new Uint8Array(arrayBuffer);
14009
- const importer = this.getImporter();
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,