diff --git a/docs/CST1229/zip.md b/docs/CST1229/zip.md index bed28a8ea9..010b435401 100644 --- a/docs/CST1229/zip.md +++ b/docs/CST1229/zip.md @@ -1,6 +1,8 @@ # Zip -The Zip extension allows you to read, create and edit .zip format files, including Scratch project and sprite files (.sb3, .sprite3). +The Zip extension allows you to read, create and edit .zip format files, including Scratch project and sprite files (.sb3, .sprite3). + +The extension handles archives **entirely in-memory**; to interact with the file system you'll have to use it alongside other extensions, like Files. In-memory zip files will be referred to as *archives* in this documentation (and in the blocks). ## Paths @@ -13,24 +15,25 @@ Most blocks in this extension work with a path format: - A `/` at the very start goes to the root directory, like `/file.txt` - A `/` at the end denotes a directory, like `folder/` - Multiple slashes in a row or trying to go above the root directory will result in an error (usually the block doing nothing or returning the empty value) + - When working with multiple archives, each archive has its own current directory which is retained while switching between them ## Archive management blocks -Blocks for creating and saving the current archive. Only one archive can be open at a time. +Blocks for creating and saving archives. --- ```scratch -create empty archive :: #a49a3a +create empty archive named [archive] :: #a49a3a ``` -Creates and opens an empty archive with nothing in it. +Creates and opens an empty archive with nothing in it. The name is used for dealing with multiple archives at time; it can be any non-empty string and does *not* have to be the archive's filename. --- ```scratch -open zip from (URL v) [https://extensions.turbowarp.org] :: #a49a3a +open zip from (URL v) [https://extensions.turbowarp.org] named [archive] :: #a49a3a ``` - Opens a .zip (or .sb3 or .sprite3...) file. +Opens a .zip (or .sb3 or .sprite3...) file. The type can be one of the following: @@ -40,14 +43,16 @@ The type can be one of the following: - **binary**: A sequence of binary bytes (like `000000010010101001101011`), without a separator. - **string**: Plain text. **Not recommended!** Text encoding behavior will likely break it, as it's a binary file. -If the file is not of zip format (e.g RAR or 7z) or is password-protected, it won't be opened. Make sure to check if it loaded successfully with the archive `is open?` block. +The name is used for dealing with multiple archives at time; it can be any non-empty string and does *not* have to be the archive's filename. + +If the file is not of zip format (e.g RAR or 7z) or is password-protected, it won't be opened. Make sure to check if it loaded successfully with the `error opening archive?` block. --- ```scratch (output zip type (data: URI v) compression level (6 v) :: #a49a3a) ``` -Save the zip data into a string, which can be saved with e.g the Files extension. +Saves the current archive into a zip data string, which can be saved with e.g the Files extension. The type can be one of the following: @@ -65,9 +70,9 @@ A compression level of 0 (no compression) is the fastest, but will often result --- ```scratch -close archive :: #a49a3a +remove current archive :: #a49a3a ``` -Closes the archive. Use after you're done working with it. +Removes the current archive from the list of opened archives. Use this after you're done working with it. --- @@ -76,6 +81,45 @@ Closes the archive. Use after you're done working with it. ``` Returns true if an archive is open. +--- + +```scratch + +``` +Returns true if the last "open archive" block used had an error (e.g if you provided an empty archive name or passed an invalid zip file). + +## Multi-archive blocks + +Multiple archives can be open at a time, but there is one "current archive" that most blocks operate on. These blocks handle switching between and using multiple archives. + +--- + +```scratch +(current archive name :: #a49a3a) +``` +Returns the name of the currently open archive (or an empty string if there isn't one). + +--- + +```scratch +(currently open archives :: #a49a3a) +``` +Returns the list of currently open archives, as a JSON array (which you can parse with the JSON extension). + +--- + +```scratch +switch to archive named [other archive] :: #a49a3a +``` +Switches the current archive to another one. If the given archive name does not exist. does nothing. If the given archive name is an empty string, switches to no currently open archive without removing any. + +--- + +```scratch +remove all archives :: #a49a3a +``` +Removes all archives that are currently open. + ## File blocks Blocks for working with files (and blocks that are general to both files and folders/directories.) @@ -111,6 +155,20 @@ Renames a file or directory to another name. If the target file already exists, --- +```scratch +copy [hello.txt] to [Copy of hello.txt] :: #a49a3a +``` +Copies a file or directory elsewhere. If the target file already exists, it will be overwritten. + +--- + +```scratch +copy [hello.txt] in [archive] to [Copy of hello.txt] in [other archive] :: #a49a3a +``` +Copies a file or directory between archives. If the target file already exists, it will be overwritten. + +--- + ```scratch delete [hello.txt] :: #a49a3a ``` @@ -192,7 +250,7 @@ Moves the current directory (the default origin of most file operations) to the ```scratch (contents of directory [.] :: #a49a3a) ``` -Returns a list of files in a directory, as JSON (which you can parse with the JSON extension). +Returns a list of files in a directory, as a JSON array (which you can parse with the JSON extension). --- diff --git a/extensions/CST1229/zip.js b/extensions/CST1229/zip.js index d289a006a5..87efc5de90 100644 --- a/extensions/CST1229/zip.js +++ b/extensions/CST1229/zip.js @@ -2,7 +2,7 @@ // ID: cst1229zip // Description: Create and edit .zip format files, including .sb3 files. // By: CST1229 -// License: MIT +// License: MIT AND LGPL-3.0 (function (Scratch) { "use strict"; @@ -15,13 +15,17 @@ class ZipExt { constructor() { - this.zip = null; + this.zips = Object.create(null); // jszip has its own "go to directory" system, but it sucks // implement our own instead - this.zipPath = null; + this.zipPaths = Object.create(null); + this.zip = null; + + this.zipError = false; Scratch.vm.runtime.on("RUNTIME_DISPOSED", () => { - this.close(); + this.closeAll(); + this.zipError = false; }); } @@ -39,15 +43,61 @@ blocks: [ { + opcode: "createEmptyAs", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("create empty archive named [NAME]"), + arguments: { + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("archive"), + }, + }, + }, + { + opcode: "openAs", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate( + "open archive from zip [TYPE] [DATA] named [NAME]" + ), + arguments: { + TYPE: { + type: Scratch.ArgumentType.STRING, + defaultValue: "URL", + menu: "fileType", + }, + DATA: { + type: Scratch.ArgumentType.STRING, + // defaultValue: "http:/localhost:8000/hello.zip", + defaultValue: "https://extensions.turbowarp.org/hello.zip", + }, + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("archive"), + }, + }, + }, + + // legacy blocks + { + hideFromPalette: true, opcode: "createEmpty", blockType: Scratch.BlockType.COMMAND, - text: Scratch.translate("create empty archive"), + text: Scratch.translate({ + default: 'create empty archive named "archive"', + description: + 'Legacy block, not important to be translated. If you do, do not translate the name "archive"', + }), arguments: {}, }, { + hideFromPalette: true, opcode: "open", blockType: Scratch.BlockType.COMMAND, - text: Scratch.translate("open zip from [TYPE] [DATA]"), + text: Scratch.translate({ + default: 'open zip from [TYPE] [DATA] named "archive"', + description: + 'Legacy block, not important to be translated. If you do, do not translate the name "archive"', + }), arguments: { TYPE: { type: Scratch.ArgumentType.STRING, @@ -83,7 +133,7 @@ { opcode: "close", blockType: Scratch.BlockType.COMMAND, - text: Scratch.translate("close archive"), + text: Scratch.translate("remove current archive"), arguments: {}, }, { @@ -92,6 +142,44 @@ text: Scratch.translate("archive is open?"), arguments: {}, }, + { + opcode: "isError", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("error opening archive?"), + arguments: {}, + }, + + "---", + + { + opcode: "currentArchive", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("current archive name"), + arguments: {}, + }, + { + opcode: "listArchives", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("currently open archives"), + arguments: {}, + }, + { + opcode: "goToArchive", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("switch to archive named [NAME]"), + arguments: { + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("other archive"), + }, + }, + }, + { + opcode: "closeAll", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("remove all archives"), + arguments: {}, + }, "---", @@ -149,6 +237,54 @@ }, }, }, + { + opcode: "copyFile", + blockType: Scratch.BlockType.COMMAND, + text: "copy [FROM] to [TO]", + arguments: { + FROM: { + type: Scratch.ArgumentType.STRING, + // Don't translate so matches default zip + defaultValue: "hello.txt", + }, + TO: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate({ + default: "Copy of hello.txt", + description: + "Windows reference. The \"hello.txt\" filename isn't translated, so don't translate it here", + }), + }, + }, + }, + { + opcode: "copyFileToArchive", + blockType: Scratch.BlockType.COMMAND, + text: "copy [FROM] in [FROMARCHIVE] to [TO] in [TOARCHIVE]", + arguments: { + FROM: { + type: Scratch.ArgumentType.STRING, + // Don't translate so matches default zip + defaultValue: "hello.txt", + }, + TO: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate({ + default: "Copy of hello.txt", + description: + "Windows reference. The \"hello.txt\" filename isn't translated, so don't translate it here", + }), + }, + FROMARCHIVE: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("archive"), + }, + TOARCHIVE: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("other archive"), + }, + }, + }, { opcode: "deleteFile", blockType: Scratch.BlockType.COMMAND, @@ -524,13 +660,15 @@ return arr; } // get a file/folder by path - getObj(path) { + getObj(path, zip = this.zip) { // JSZip.prototype.files seems to be a null-prototype object // it should be safe doing this - return this.zip.files[path.substring(1)] || this.zip.files[path]; + return ( + this.zips[zip].files[path.substring(1)] || this.zips[zip].files[path] + ); } // create folders up to a certain path - createFolders(path) { + createFolders(path, zip) { try { path = this.normalize(path, "."); @@ -539,23 +677,59 @@ if (folder === "") continue; if (currentPath !== "") currentPath += "/"; currentPath += folder; - this.zip.folder(currentPath); + zip.folder(currentPath); } } catch (e) { console.error(`Zip extension: Error creating folders for ${path}:`, e); } } + // Go back until we are in a directory that exists + goBackFolders(zip) { + const split = this.zipPaths[zip].split("/"); + this.zipPaths[zip] = ""; + + let i = 0; + while (i < split.length) { + if (split[i] === "") { + i++; + continue; + } + const newPath = this.zipPaths[zip] + split[i] + "/"; + if (!this.getObj(newPath, zip)) break; + this.zipPaths[zip] = newPath; + i++; + } + if (this.zipPaths[zip] === "") this.zipPaths[zip] = "/"; + } /// Blocks - createEmpty() { - this.close(); + createEmptyAs({ NAME }) { + this.zipError = false; + NAME = Scratch.Cast.toString(NAME); + if (!NAME) { + this.zipError = true; + return; + } + + this.zip = NAME; - this.zip = new JSZip(); - this.zipPath = "/"; + this.zips[this.zip] = new JSZip(); + this.zipPaths[this.zip] = "/"; } - async open({ TYPE, DATA }) { - this.close(); + createEmpty() { + this.createEmptyAs({ NAME: "archive" }); + } + + async openAs({ TYPE, DATA, NAME }) { + this.zipError = false; + this.zip = null; + NAME = Scratch.Cast.toString(NAME); + if (!NAME) { + this.zipError = true; + return; + } + try { DATA = Scratch.Cast.toString(DATA); @@ -586,12 +760,21 @@ break; } - this.zip = await JSZip.loadAsync(DATA, { createFolders: true }); - this.zipPath = "/"; + this.zip = NAME; + + this.zips[this.zip] = await JSZip.loadAsync(DATA, { + createFolders: true, + }); + this.zipPaths[this.zip] = "/"; } catch (e) { + this.zipError = true; + this.zip = null; console.error("Zip extension: Could not open zip file.", e); } } + open({ TYPE, DATA }) { + return this.openAs({ TYPE, DATA, NAME: "archive" }); + } async getZip({ TYPE, COMPRESSION }) { if (!this.zip) return ""; try { @@ -607,13 +790,13 @@ switch (TYPE) { case "text": case "string": - return await this.zip.generateAsync({ + return await this.zips[this.zip].generateAsync({ type: "binarystring", ...options, }); case "base64": case "data: URL": { - let data = await this.zip.generateAsync({ + let data = await this.zips[this.zip].generateAsync({ type: "base64", ...options, }); @@ -622,7 +805,7 @@ return data; } case "hex": { - const data = await this.zip.generateAsync({ + const data = await this.zips[this.zip].generateAsync({ type: "array", ...options, }); @@ -631,7 +814,7 @@ .join(""); } case "binary": { - const data = await this.zip.generateAsync({ + const data = await this.zips[this.zip].generateAsync({ type: "array", ...options, }); @@ -650,17 +833,44 @@ } } close() { + delete this.zips[this.zip]; + delete this.zipPaths[this.zip]; + this.zip = null; + } + closeAll() { + this.zips = Object.create(null); + this.zipPaths = Object.create(null); this.zip = null; - this.zipPath = null; } isOpen() { return !!this.zip; } + isError() { + return this.zipError; + } + + currentArchive() { + if (!this.zip) return ""; + return this.zip; + } + goToArchive({ NAME }) { + NAME = Scratch.Cast.toString(NAME); + if (!NAME) { + this.zip = null; + return; + } + if (!this.zips[NAME]) return; + + this.zip = NAME; + } + listArchives() { + return JSON.stringify(Object.keys(this.zips)); + } exists({ OBJECT }) { try { return !!this.getObj( - this.normalize(this.zipPath, Scratch.Cast.toString(OBJECT)) + this.normalize(this.zipPaths[this.zip], Scratch.Cast.toString(OBJECT)) ); } catch (e) { return false; @@ -672,7 +882,7 @@ FILE = Scratch.Cast.toString(FILE); TYPE = Scratch.Cast.toString(TYPE); try { - const path = this.normalize(this.zipPath, FILE); + const path = this.normalize(this.zipPaths[this.zip], FILE); if (path.endsWith("/")) return ""; const obj = this.getObj(path); if (!obj || obj.dir) return ""; @@ -717,7 +927,7 @@ CONTENT = Scratch.Cast.toString(CONTENT); TYPE = Scratch.Cast.toString(TYPE); try { - let path = this.normalize(this.zipPath, FILE); + let path = this.normalize(this.zipPaths[this.zip], FILE); if (path.endsWith("/")) return; const obj = this.getObj(path); @@ -727,7 +937,7 @@ switch (TYPE) { case "text": - this.zip.file(path, CONTENT, { + this.zips[this.zip].file(path, CONTENT, { createFolders: true, }); break; @@ -736,7 +946,7 @@ // compatibility if (TYPE === "data: URL") CONTENT = CONTENT.substring(CONTENT.indexOf(",")); - this.zip.file(path, CONTENT, { + this.zips[this.zip].file(path, CONTENT, { base64: true, createFolders: true, }); @@ -745,7 +955,7 @@ case "URL": { const resp = await Scratch.fetch(CONTENT); - this.zip.file(path, await resp.blob(), { + this.zips[this.zip].file(path, await resp.blob(), { base64: true, createFolders: true, }); @@ -756,7 +966,7 @@ if (!/^(?:[0-9A-F]{2})*$/i.test(CONTENT)) return ""; const dataArr = this.splitIntoParts(CONTENT, 2); const data = Uint8Array.from(dataArr.map((o) => parseInt(o, 16))); - this.zip.file(path, data, { + this.zips[this.zip].file(path, data, { createFolders: true, }); } @@ -766,7 +976,7 @@ if (!/^(?:[01]{8})*$/i.test(CONTENT)) return ""; const dataArr = this.splitIntoParts(CONTENT, 8); const data = Uint8Array.from(dataArr.map((o) => parseInt(o, 2))); - this.zip.file(path, data, { + this.zips[this.zip].file(path, data, { createFolders: true, }); } @@ -781,28 +991,44 @@ ); } } - renameFile({ FROM, TO }) { - if (!this.zip) return; - const renameOne = (from, to) => { - const obj = this.zip.files[from]; - this.zip.files[to] = obj; - obj.name = to; - delete this.zip.files[from]; + async _renameFile(from, fromZipName, to, toZipName, isCopy) { + const renameOne = async (from, fromZip, to, toZip) => { + if (from === to && fromZip == toZip) return; + const obj = fromZip.files[from]; + if (isCopy) { + let copied; + if (obj.dir) { + copied = toZip.folder(to); + } else { + copied = toZip.file(to, await obj.async("uint8array"), obj.options); + } + // copy properties over + copied.date = structuredClone(obj.date); + copied.dosPermissions = obj.dosPermissions; + copied.unixPermissions = obj.unixPermissions; + copied.comment = obj.comment; + } else { + toZip.files[to] = obj; + obj.name = to; + delete fromZip.files[from]; + } }; - FROM = Scratch.Cast.toString(FROM); - TO = Scratch.Cast.toString(TO); + let fromZip = this.zips[fromZipName]; + let toZip = this.zips[toZipName]; + if (!fromZip || !toZip) return; + try { - let fromPath = this.normalize(this.zipPath, FROM); - let fromObj = this.getObj(fromPath); + let fromPath = this.normalize(this.zipPaths[fromZipName], from); + let fromObj = this.getObj(fromPath, fromZipName); if (!fromObj && !fromPath.endsWith("/")) { fromPath += "/"; - fromObj = this.getObj(fromPath); + fromObj = this.getObj(fromPath, fromZipName); } if (!fromObj) return; - let toPath = this.normalize(this.zipPath, TO); - const replacedTo = TO.replaceAll(/\\/g, "/"); + let toPath = this.normalize(this.zipPaths[toZipName], to); + const replacedTo = to.replaceAll(/\\/g, "/"); const slashes = replacedTo.split("/").length - 1; if ( slashes <= +fromObj.dir && @@ -822,7 +1048,7 @@ // If this is a file, just renaming this one is enough if (!fromObj.dir) { - renameOne(fromPath, toPath); + await renameOne(fromPath, fromZip, toPath, toZip); return; } @@ -831,53 +1057,76 @@ if (!toPath.endsWith("/")) toPath += "/"; // Move current directory - if (this.zipPath.substring(1).startsWith(fromPath)) { - this.zipPath = - "/" + toPath + this.zipPath.substring(1).substring(fromPath.length); + if ( + !isCopy && + this.zipPaths[fromZipName].substring(1).startsWith(fromPath) + ) { + if (fromZip === toZip) { + this.zipPaths[fromZipName] = + "/" + + toPath + + this.zipPaths[fromZipName] + .substring(1) + .substring(fromPath.length); + } else { + this.goBackFolders(fromZip); + } } - for (const path in this.zip.files) { + for (const path in fromZip.files) { if (!path.startsWith(fromPath)) continue; const extraPath = path.substring(fromPath.length); - renameOne(path, toPath + extraPath); + await renameOne(path, fromZip, toPath + extraPath, toZip); } - this.createFolders(toPath); + this.createFolders(toPath, toZip); } catch (e) { - console.error(`Zip extension: Error renaming ${FROM} to ${TO}:`, e); + console.error( + `Zip extension: Error ${isCopy ? "copying" : "renaming"} ${from} to ${to}:`, + e + ); } } + + renameFile({ FROM, TO }) { + if (!this.zip) return; + + FROM = Scratch.Cast.toString(FROM); + TO = Scratch.Cast.toString(TO); + this._renameFile(FROM, this.zip, TO, this.zip, false); + } + copyFile({ FROM, TO }) { + if (!this.zip) return; + + FROM = Scratch.Cast.toString(FROM); + TO = Scratch.Cast.toString(TO); + this._renameFile(FROM, this.zip, TO, this.zip, true); + } + copyFileToArchive({ FROM, FROMARCHIVE, TO, TOARCHIVE }) { + if (!this.zip) return; + + FROM = Scratch.Cast.toString(FROM); + FROMARCHIVE = Scratch.Cast.toString(FROMARCHIVE); + TO = Scratch.Cast.toString(TO); + TOARCHIVE = Scratch.Cast.toString(TOARCHIVE); + this._renameFile(FROM, FROMARCHIVE, TO, TOARCHIVE, true); + } deleteFile({ FILE }) { if (!this.zip) return; FILE = Scratch.Cast.toString(FILE); try { - let path = this.normalize(this.zipPath, FILE); + let path = this.normalize(this.zipPaths[this.zip], FILE); if (!this.getObj(path)) return; if (path === "/") return; const shouldGoBack = - this.getObj(path).dir && this.zipPath.startsWith(path); + this.getObj(path).dir && this.zipPaths[this.zip].startsWith(path); if (path.startsWith("/")) path = path.substring(1); - this.zip.remove(path); + this.zips[this.zip].remove(path); if (shouldGoBack) { - // Go back until we are in a directory that exists - const split = this.zipPath.split("/"); - this.zipPath = ""; - - let i = 0; - while (i < split.length) { - if (split[i] === "") { - i++; - continue; - } - const newPath = this.zipPath + split[i] + "/"; - if (!this.getObj(newPath)) break; - this.zipPath = newPath; - i++; - } - if (this.zipPath === "") this.zipPath = "/"; + this.goBackFolders(this.zip); } } catch (e) { console.error(`Zip extension: Error deleting file ${FILE}:`, e); @@ -891,7 +1140,7 @@ FILE = Scratch.Cast.toString(FILE); VALUE = Scratch.Cast.toString(VALUE); try { - const normalized = this.normalize(this.zipPath, FILE); + const normalized = this.normalize(this.zipPaths[this.zip], FILE); const obj = this.getObj(normalized); if (!obj) return ""; switch (META) { @@ -924,7 +1173,7 @@ META = Scratch.Cast.toString(META); FILE = Scratch.Cast.toString(FILE); try { - const normalized = this.normalize(this.zipPath, FILE); + const normalized = this.normalize(this.zipPaths[this.zip], FILE); const obj = this.getObj(normalized); if (!obj) return ""; switch (META) { @@ -973,11 +1222,11 @@ if (!this.zip) return; DIR = Scratch.Cast.toString(DIR); try { - let newPath = this.normalize(this.zipPath, DIR); + let newPath = this.normalize(this.zipPaths[this.zip], DIR); if (!newPath.endsWith("/")) newPath += "/"; if (newPath.startsWith("/")) newPath = newPath.substring(1); if (this.getObj(newPath)) return; - this.zip.folder(newPath); + this.zips[this.zip].folder(newPath); } catch (e) { console.error(`Error creating directory ${DIR}:`, e); } @@ -986,10 +1235,10 @@ if (!this.zip) return; DIR = Scratch.Cast.toString(DIR); try { - let newPath = this.normalize(this.zipPath, DIR); + let newPath = this.normalize(this.zipPaths[this.zip], DIR); if (!newPath.endsWith("/")) newPath += "/"; if (!this.getObj(newPath) && newPath !== "/") return; - this.zipPath = newPath; + this.zipPaths[this.zip] = newPath; } catch (e) { console.error(`Error going to directory ${DIR}:`, e); } @@ -1000,13 +1249,13 @@ DIR = Scratch.Cast.toString(DIR); if (!DIR.endsWith("/")) DIR += "/"; - const normalized = this.normalize(this.zipPath, DIR); + const normalized = this.normalize(this.zipPaths[this.zip], DIR); if (!this.getObj(normalized) && normalized !== "/") return ""; const dir = normalized.substring(1); const length = dir.length; return JSON.stringify( - Object.values(this.zip.files) + Object.values(this.zips[this.zip].files) .filter((obj) => { // Above the current directory if (!obj.name.startsWith(dir)) return false; @@ -1025,16 +1274,16 @@ } } currentDir() { - return this.zipPath || ""; + return this.zipPaths[this.zip] || ""; } setComment({ COMMENT }) { if (!this.zip) return; - this.zip.comment = Scratch.Cast.toString(COMMENT); + this.zips[this.zip].comment = Scratch.Cast.toString(COMMENT); } getComment({ COMMENT }) { if (!this.zip) return ""; - return this.zip.comment || ""; + return this.zips[this.zip].comment || ""; } normalizePath({ ORIGIN, PATH }) {