UNPKG

@minecraft/creator-tools

Version:

Minecraft Creator Tools command line and libraries.

1,290 lines (1,289 loc) 75.5 kB
"use strict"; // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.EncodingType = exports.AllowedExtensionsSet = exports.PackFolderHints = exports.PackContainerFolderHints = exports.VersionCoalescingVersionsToConsider = exports.VersionCoalescingSizeThresholdBytes = exports.VersionCoalescingTimeThresholdMs = exports.MaxShareableContentStringLength = void 0; const IFile_1 = require("./IFile"); const DifferenceSet_1 = __importDefault(require("./DifferenceSet")); const IFolderDifference_1 = require("./IFolderDifference"); const IFileDifference_1 = require("./IFileDifference"); const Utilities_1 = __importDefault(require("../core/Utilities")); const ZipStorage_1 = __importDefault(require("./ZipStorage")); const Log_1 = __importDefault(require("../core/Log")); const IStorage_1 = require("./IStorage"); const Storage_1 = __importDefault(require("./Storage")); const BasicValidators_1 = require("./BasicValidators"); const JsonUtilities_1 = __importDefault(require("../core/JsonUtilities")); exports.MaxShareableContentStringLength = 65536; exports.VersionCoalescingTimeThresholdMs = 10000; exports.VersionCoalescingSizeThresholdBytes = 1024; exports.VersionCoalescingVersionsToConsider = 100; exports.PackContainerFolderHints = [ "bps", "rps", "content/resource_packs", "content/behavior_packs", "resource packs", "behavior packs", "behavior_packs", "resource_packs", "skin pack", "skin packs", "content/skin_packs", "content/world_templates", "world_template", "worlds", "world_templates", ]; exports.PackFolderHints = [ "behavior pack", "resource pack", "resource_pack", "behavior_pack", "skin_pack", "world", "world_template", "rp", "bp", ]; // part of security/reliability and defense in depth is to only allow our file functions to work with files from an allow list // this list is also replicated in /public/preload.js exports.AllowedExtensionsSet = new Set([ "js", "ts", "json", "md", "png", "css", "woff", "ttf", "woff2", "jpg", "gitignore", "jpeg", "gif", "lang", "fsb", "map", "yml", "ico", "ogg", "flac", "psd", "nojekyll", "mjs", "env", "wav", "tga", "old", "mc", "", "zip", "wlist", "brarchive", "nbt", "webm", "svg", "otf", "ttf", "bin", "obj", "pdn", "py", "hdr", "h", "fontdata", "mcstructure", "mcworld", "mcproject", "material", "vertex", "md", "geometry", "fragment", "map", "js.map", "mctemplate", "mcfunction", "mcaddon", "mcpack", "html", "dat", "dat_old", "txt", "ldb", "log", "in", "cmake", ]); const IgnoreExtensionsSet = new Set(["ds_store", "brarchive"]); const IgnoreFileNames = new Set(["thumbs.db", "desktop.ini"]); const IgnoreFolders = [ "__MACOSX", "credits", "shaders", "hbui", "ray_tracing", "node_modules", "test", "__brarchive", "metadata", ]; const _minecraftProjectFolderNames = [ "behavior_packs", "resource_packs", "worlds", "world", "world_template", "skin_pack", "scripts", "content", "marketing art", "store art", "db", "texts", "animation_controllers", "blocks", "structures", "entities", "functions", "items", "dialogue", "animations", "entity", "materials", "models", "textures", "fogs", "materials", "particles", "ui", ]; var EncodingType; (function (EncodingType) { EncodingType[EncodingType["ByteBuffer"] = 0] = "ByteBuffer"; EncodingType[EncodingType["Utf8String"] = 1] = "Utf8String"; })(EncodingType || (exports.EncodingType = EncodingType = {})); class StorageUtilities { static standardFolderDelimiter = "/"; static textEncoder = new TextEncoder(); /** * Debug flag: When true, getJsonObjectWithComments will use regular JSON.parse * instead of comment-json to help isolate memory issues. Set via environment * variable MCT_BYPASS_COMMENT_JSON=1 or programmatically. */ static bypassCommentJson = typeof process !== "undefined" && process.env && process.env.MCT_BYPASS_COMMENT_JSON === "1"; /** * Counter for tracking how many times comment-json parsing is invoked. * Useful for debugging memory issues. */ static commentJsonParseCount = 0; /** * Log every Nth comment-json parse call (0 = disabled) */ static commentJsonLogFrequency = 0; static isUsableFile(path) { const extension = StorageUtilities.getTypeFromName(path); return exports.AllowedExtensionsSet.has(extension); } static canIgnoreFileName(fileName) { return IgnoreFileNames.has(fileName.toLowerCase()); } static canIgnoreFileExtension(extension) { return IgnoreExtensionsSet.has(extension); } static isIgnorableFolder(folderName) { const folderNameLower = folderName.toLowerCase(); return ((folderNameLower.startsWith(".") && folderNameLower !== ".mcp" && folderNameLower !== ".vscode") || IgnoreFolders.includes(folderNameLower)); } static getSerializationOfChangeList(versionContentList) { let str = ""; for (const versionContent of versionContentList) { str += "|" + versionContent.file.extendedPath + "\n"; } return str; } static getEncodingByFileName(name) { const fileType = this.getTypeFromName(name); const nameLow = name.toLowerCase(); switch (fileType) { case "mcstructure": case "zip": case "dat": case "dat_old": case "ldb": case "ico": case "tga": case "hdr": case "ogg": case "flac": case "wav": case "gif": case "jpeg": case "jpg": case "png": case "psd": case "mp3": case "fsb": case "woff": case "woff2": case "ttf": case "pdb": case "exe": case "nbt": case "mcworld": case "mcproject": case "mctemplate": case "mcpack": case "mcaddon": return EncodingType.ByteBuffer; } if (fileType === "" && nameLow.startsWith("manifest-")) { return EncodingType.ByteBuffer; } // LevelDB CURRENT file (no extension) if (fileType === "" && nameLow === "current") { return EncodingType.ByteBuffer; } // LevelDB LOG file (no extension, write-ahead log) if (fileType === "" && (nameLow === "log" || nameLow === "lock")) { return EncodingType.ByteBuffer; } // Linux executables (no extension) if (fileType === "" && nameLow === "bedrock_server") { return EncodingType.ByteBuffer; } // LevelDB LOG.old file if (nameLow === "log.old") { return EncodingType.ByteBuffer; } // LevelDB LOG files (e.g., 000003.log) - all .log files in LevelDB are binary if (fileType === "log") { return EncodingType.ByteBuffer; } return EncodingType.Utf8String; } static absolutize(path) { if (!path.startsWith(StorageUtilities.standardFolderDelimiter)) { path = StorageUtilities.standardFolderDelimiter + path; } return path; } static stripExtension(path) { const lastPeriodEnd = path.lastIndexOf("."); if (lastPeriodEnd >= 0) { path = path.substring(0, lastPeriodEnd); } return path; } static getUniqueChildFolderName(name, folder) { let num = 1; let nameCand = name; let isUnique = false; while (!isUnique) { isUnique = true; for (const childFolderName in folder.folders) { if (StorageUtilities.canonicalizeName(childFolderName) === StorageUtilities.canonicalizeName(nameCand)) { isUnique = false; } } if (!isUnique) { nameCand = name + " " + num; num++; } } return nameCand; } static ensureEndsDelimited(path) { if (!path.endsWith(StorageUtilities.standardFolderDelimiter)) { path = path + StorageUtilities.standardFolderDelimiter; } if (path.startsWith("." + StorageUtilities.standardFolderDelimiter)) { path = path.substring(1); } else if (!path.startsWith(StorageUtilities.standardFolderDelimiter)) { path = StorageUtilities.standardFolderDelimiter + path; } return path; } static ensureEndsWithDelimiter(path) { if (!path.endsWith(StorageUtilities.standardFolderDelimiter)) { path = path + StorageUtilities.standardFolderDelimiter; } return path; } /*** * returns true if IFile argument is a .json file */ static isJsonFile(file) { return !!file && file.fullPath.endsWith(".json"); } /*** * Checks binary file contents for a UTF8 Byte Order Mark * * falsey contents will return false */ static hasUTF8ByteOrderMark(bytes) { if (!bytes) { return false; } return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; } /*** * Normalizes file contents by converting non-binary contents into binary * * falsey content will return as null */ static getContentsAsBinary(file) { if (!file || !file.content) return null; if (typeof file.content === "string") { return StorageUtilities.textEncoder.encode(file.content); } return file.content; } static ensureStartsWithDelimiter(path) { if (!path.startsWith(StorageUtilities.standardFolderDelimiter)) { path = StorageUtilities.standardFolderDelimiter + path; } return path; } static ensureNotStartsWithDelimiter(path) { if (path.startsWith(StorageUtilities.standardFolderDelimiter)) { path = path.substring(1); } return path; } static getRootAndFocusPathFromInputPath(path) { path = StorageUtilities.canonicalizePath(path); let pathSegs = path.split("/"); let i = pathSegs.length - 1; let splitPoint = i; // subtract out the file part of the path if (pathSegs[i].indexOf(".") >= 0) { i--; splitPoint = i; } for (; i >= 1; i--) { const seg = pathSegs[i]; if (seg === "static-assets" && i > 2) { splitPoint = i - 2; break; } if (exports.PackContainerFolderHints.includes(seg)) { splitPoint = i; break; } } let basePath = pathSegs.slice(0, splitPoint).join("/").trim(); if (!basePath.endsWith("/")) { basePath += "/"; } let focusPath = pathSegs.slice(splitPoint).join("/").trim(); if (focusPath.length > 2) { if (!focusPath.startsWith("/")) { focusPath = "/" + focusPath; } } else { focusPath = undefined; } return { basePath: basePath, focusPath: focusPath, }; } static joinPath(pathA, pathB) { let fullPath = pathA; if (!fullPath.endsWith(StorageUtilities.standardFolderDelimiter)) { fullPath += StorageUtilities.standardFolderDelimiter; } if (pathB.startsWith("/")) { fullPath += pathB.substring(1, pathB.length); } else { fullPath += pathB; } return fullPath; } static getMimeTypeFromName(name) { switch (StorageUtilities.getTypeFromName(name)) { case "js": return "application/javascript"; case "ts": return "application/typescript"; case "json": return "application/json"; case "mcworld": case "mctemplate": case "mcproject": case "mcaddon": case "mcpack": case "zip": return "application/zip"; case "mcstucture": case "hdr": case "fsb": return "application/octet-stream"; case "mcfunction": case "material": case "env": case "lang": return "text/plain"; case "wav": return "audio/wav"; case "mp3": return "audio/mp3"; case "ogg": return "audio/ogg"; case "flac": return "audio/flac"; case "psd": return "image/vnd.adobe.photoshop"; case "jpg": case "jpeg": return "image/jpeg"; case "png": return "image/png"; case "gif": return "image/gif"; case "webp": return "image/webp"; case "svg": return "image/svg+xml"; case "bmp": return "image/bmp"; case "ico": return "image/x-icon"; case "tiff": case "tga": return "image/tiff"; case "md": return "text/markdown"; case "html": case "htm": return "text/html"; case "css": return "text/css"; case "xml": return "application/xml"; case "yaml": case "yml": return "application/yaml"; default: return "application/octet-stream"; } } static getMimeType(file) { return StorageUtilities.getMimeTypeFromName(file.name); } static isImageMimeType(mimeType) { return mimeType.startsWith("image/"); } static getContentAsString(file) { if (typeof file.content === "string") { return file.content; } else if (file.content instanceof Uint8Array) { return "data:" + StorageUtilities.getMimeType(file) + ";base64," + Utilities_1.default.uint8ArrayToBase64(file.content); } return undefined; } static sortChangeList(changeList) { changeList.sort((a, b) => { if (!a.versionTime && !b.versionTime) { return 0; } if (!a.versionTime) { return -1; } if (!b.versionTime) { return 1; } return a.versionTime.getTime() - b.versionTime.getTime(); }); } // We don't want a new version every time the user hits a key stroke, so, look to see if it makes sense to // remove "trivial" versions compared to the latest update static coalesceVersions(versionList) { const latestUpdate = versionList[versionList.length - 1]; let removingVersion = false; if (!latestUpdate.versionTime || !latestUpdate.content) { return versionList; } let latestVersionTime = latestUpdate.versionTime.getTime(); let latestVersionSize = latestUpdate.content.length; const contentUpdatesToRemove = {}; const firstInstanceOfFile = {}; if (versionList.length >= 2) { // get to the first instance of a particular file we're going to deal with for (let i = versionList.length - 2; i >= Math.max(0, versionList.length - exports.VersionCoalescingVersionsToConsider); i--) { const current = versionList[i]; if (current && current.file) { firstInstanceOfFile[current.file.extendedPath] = current.id; } } // moving backwards over the version list - considering a max set of VersionCoalescingVersionsToConsider, // coalesce versions that are semantically similar enough to the latest version by essentially removing minor // from the version list // note that we are only considering coalescing into the latest version (lastestUpdate) for (let i = versionList.length - 2; i >= Math.max(0, versionList.length - exports.VersionCoalescingVersionsToConsider); i--) { const current = versionList[i]; if (current.file === latestUpdate.file) { // is this change "minor" compared to the latest version if (firstInstanceOfFile[current.file.extendedPath] !== current.id && current.versionTime && latestVersionTime - current.versionTime.getTime() < exports.VersionCoalescingTimeThresholdMs && current.content && Math.abs(latestVersionSize - current.content.length) < exports.VersionCoalescingSizeThresholdBytes) { contentUpdatesToRemove[current.id] = true; removingVersion = true; if (!latestUpdate.startVersionTime || current.versionTime.getTime() < latestUpdate.startVersionTime.getTime()) { latestUpdate.startVersionTime = current.versionTime; } } } } } if (!removingVersion) { return versionList; } const newList = []; let previousMajorUpdate = undefined; for (const version of versionList) { if (!contentUpdatesToRemove[version.id]) { newList.push(version); if (version.file === latestUpdate.file) { if (previousMajorUpdate !== undefined) { if (previousMajorUpdate.content && version.content) { const byteDiff = version.content.length - previousMajorUpdate.content.length; if (byteDiff > 0) { version.description = "+" + byteDiff; } else if (byteDiff < 0) { version.description = "-" + Math.abs(byteDiff); } } } previousMajorUpdate = version; } } } // remove trivial versions from the per-file list too const newPerFileList = []; for (const version of latestUpdate.file.priorVersions) { if (!contentUpdatesToRemove[version.id]) { newPerFileList.push(version); } } latestUpdate.file.priorVersions = newPerFileList; return newList; } static getAvailableFolderName(folder) { // if we're inside of an MCPack or zip, the folder is "/" but the name of the mcpack file potentially // provides hints, so scoop out the name of the parent zip let basePathName = StorageUtilities.canonicalizeName(folder.name); if ((basePathName === "/" || basePathName === "") && folder.extendedPath.length > 1) { let hashIndex = folder.extendedPath.indexOf("#"); if (hashIndex > 0) { basePathName = folder.extendedPath.substring(0, hashIndex); let lastPeriod = basePathName.lastIndexOf("."); if (lastPeriod > 0) { basePathName = basePathName.substring(0, lastPeriod); } let lastSlash = basePathName.lastIndexOf("/"); if (lastSlash >= 0 && lastSlash < basePathName.length - 1) { basePathName = basePathName.substring(lastSlash + 1); } } } return basePathName; } static async getFileStorageFolder(file) { let zipStorage = file.fileContainerStorage; if (!zipStorage) { zipStorage = await ZipStorage_1.default.loadFromFile(file); if (!zipStorage) { return undefined; } if (zipStorage.errorStatus === IStorage_1.StorageErrorStatus.unprocessable) { file.errorStateMessage = zipStorage.errorMessage; return file.errorStateMessage; } file.fileContainerStorage = zipStorage; file.fileContainerStorage.storagePath = file.storageRelativePath + "#"; } return zipStorage.rootFolder; } static getContaineredFileLeafPath(path) { if (!path) { return; } const lastHash = path.lastIndexOf("#"); if (lastHash >= 0) { path = path.substring(lastHash + 1); } return path; } static isMinecraftInternalFolder(folder) { const nameCanon = folder.name.toLowerCase(); return _minecraftProjectFolderNames.includes(nameCanon); } static isContainerFile(path) { const extension = StorageUtilities.getTypeFromName(path); if (extension === "zip" || extension === "mcworld" || extension === "mcproject" || extension === "mctemplate" || extension === "mcpack" || extension === "mcaddon") { return true; } return false; } static isFileStorageItem(file) { return this.isContainerFile(file.name); } static canonicalizeName(name) { name = name.trim(); //.toLowerCase(); if (name.startsWith("/") || name.startsWith("\\")) { name = name.substring(1, name.length); } if (name.endsWith("/") || name.endsWith("\\")) { name = name.substring(0, name.length - 1); } // constructor is a keyword that will cause array existence checks to fail in interesting ways if (name === "constructor") { name = "__constructor"; } name = name.replace(/%20/g, " "); name = name.replace(/%28/g, "("); name = name.replace(/%29/g, ")"); if (!Utilities_1.default.isUsableAsObjectKey(name)) { name = "named_" + name; } return name; } static isPathEqual(pathA, pathB) { return StorageUtilities.canonicalizePath(pathA) === StorageUtilities.canonicalizePath(pathB); } static canonicalizePath(path) { path = path.trim(); // .toLowerCase(); path = path.replace(/\\/g, "/"); path = path.replace(/%20/g, " "); path = path.replace(/%28/g, "("); path = path.replace(/%29/g, ")"); return path; } static canonicalizePathAsFileName(path) { let result = path.trim().toLowerCase(); path = path.replace(/%20/g, " "); path = path.replace(/%28/g, "("); path = path.replace(/%29/g, ")"); result = result.replace(/:/g, "_"); result = result.replace(/\//g, "-"); result = result.replace(/\\/g, "-"); result = result.replace(/%/g, "-"); result = result.replace(/--/g, "-"); result = result.replace(/--/g, "-"); result = result.replace(/\?/g, "-"); result = result.replace(/\|/g, "-"); return result; } static ensureFileNameIsSafe(path) { let result = path.trim().toLowerCase(); path = path.replace(/%20/g, " "); path = path.replace(/%28/g, "("); path = path.replace(/%29/g, ")"); result = result.replace(/:/g, "_"); result = result.replace(/\//g, "-"); result = result.replace(/\\/g, "-"); result = result.replace(/%/g, "-"); result = result.replace(/--/g, "-"); result = result.replace(/`/g, "-"); result = result.replace(/'/g, "-"); result = result.replace(/–/g, "-"); return result; } static hasPathSeparator(path) { if (!path) { return false; } let lastSlash = path.lastIndexOf("/", path.length - 1); if (lastSlash >= 0) { return true; } lastSlash = path.lastIndexOf("\\", path.length - 1); if (lastSlash >= 0) { return true; } return false; } static getLeafName(path) { let name = path; if (name.endsWith("/")) { name = name.substring(0, name.length - 1); } if (name.endsWith("\\")) { name = name.substring(0, name.length - 1); } let lastSlash = name.lastIndexOf("/", path.length - 1); if (lastSlash >= 0) { name = name.substring(lastSlash + 1, name.length); } lastSlash = name.lastIndexOf("\\", name.length - 1); if (lastSlash >= 0) { name = name.substring(lastSlash + 1, name.length); } return name; } static getFolderPath(path) { let lastSlash = path.lastIndexOf("/", path.length - 1); if (lastSlash >= 0 && lastSlash < path.length - 1) { path = path.substring(0, lastSlash + 1); } else { lastSlash = path.lastIndexOf("\\", path.length - 1); if (lastSlash >= 0 && lastSlash < path.length - 1) { path = path.substring(0, lastSlash + 1); } } return path; } static getParentFolderNameFromPath(path) { let lastSlash = path.lastIndexOf("/", path.length - 1); if (lastSlash >= 0 && lastSlash < path.length - 1) { const nextLastSlash = path.lastIndexOf("/", lastSlash - 1); return path.substring(nextLastSlash + 1, lastSlash); } else { lastSlash = path.lastIndexOf("\\", path.length - 1); if (lastSlash >= 0 && lastSlash < path.length - 1) { const nextLastSlash = path.lastIndexOf("/", lastSlash - 1); return path.substring(nextLastSlash + 1, lastSlash); } } return undefined; } static removeContainerExtension(name) { let nameW = name.trim(); if (nameW.endsWith(".zip")) { nameW = nameW.substring(0, nameW.length - 4); } return nameW; } // returns path relative to folder, with the file extension removed, as is commonly used in Minecraft resource references static getBaseRelativePath(file, folder) { let relativePath = file.getFolderRelativePath(folder); if (relativePath) { relativePath = StorageUtilities.getBaseFromName(relativePath).toLowerCase(); relativePath = StorageUtilities.ensureNotStartsWithDelimiter(relativePath); } return relativePath; } static getBaseFromName(name) { const nameW = name.trim(); const lastPeriod = nameW.lastIndexOf("."); if (lastPeriod < 0) { return nameW; } return nameW.substring(0, lastPeriod); } static convertFolderPlaceholders(path) { return this.convertFolderPlaceholdersPartial(path, 0); } static convertFolderPlaceholdersPartial(path, startIndex) { if (startIndex === undefined) { startIndex = 4; // default is to ignore the <pt_ at the start. } let pathTokenStart = path.indexOf("<pt_", startIndex); while (pathTokenStart >= startIndex) { let pathTokenEnd = path.indexOf(">", pathTokenStart + 4); if (pathTokenEnd > pathTokenStart) { path = path.substring(0, pathTokenStart) + path.substring(pathTokenStart + 4, pathTokenEnd) + path.substring(pathTokenEnd + 1); pathTokenStart = path.indexOf("<pt_", pathTokenStart); } else { pathTokenStart = path.indexOf("<pt_", pathTokenStart + 1); } } return path; } /** * Well-known path token prefixes used in Electron for folder abstraction. * Maps the token prefix (e.g., "DOCP") to a user-friendly display name. */ static _friendlyTokenNames = { DOCP: "documents", BDRK: "Minecraft", BDPV: "Minecraft Preview", EDUR: "Minecraft Education", EDUP: "Minecraft Education Preview", MCPE: "Minecraft PE", }; /** * Strips the trailing random suffix (dash + 6 lowercase chars) that is appended * to Electron path tokens for uniqueness. For example, "base-packs-4kp0sp" becomes "base-packs". */ static _stripTokenSuffix(tokenContent) { const lastDash = tokenContent.lastIndexOf("-"); if (lastDash >= 1 && lastDash === tokenContent.length - 7) { // Verify the suffix is all lowercase alphanumeric (the random ID format) const suffix = tokenContent.substring(lastDash + 1); if (/^[a-z0-9]{6}$/.test(suffix)) { return tokenContent.substring(0, lastDash); } } return tokenContent; } /** * Converts a path or name containing Electron folder tokens into a user-friendly * display string. Handles both `<pt_name-random>` project tokens (stripping the * `<pt_>` wrapper and random suffix) and well-known tokens like `<DOCP>`, `<BDRK>`, etc. * * Examples: * "<pt_base-packs-4kp0sp>" -> "base-packs" * "<DOCP>" -> "documents" * "<DOCP>/my-project/" -> "documents/my-project/" * "Opening <pt_my-addon-ab12cd>..." -> "Opening my-addon..." */ static getFriendlyDisplayName(path) { // First, replace well-known tokens like <DOCP>, <BDRK>, etc. for (const token in this._friendlyTokenNames) { const tokenTag = "<" + token + ">"; while (path.indexOf(tokenTag) >= 0) { path = path.replace(tokenTag, this._friendlyTokenNames[token]); } } // Then, replace <pt_...> tokens, stripping the random suffix let ptStart = path.indexOf("<pt_"); while (ptStart >= 0) { const ptEnd = path.indexOf(">", ptStart + 4); if (ptEnd > ptStart) { const tokenContent = path.substring(ptStart + 4, ptEnd); const friendlyName = this._stripTokenSuffix(tokenContent); path = path.substring(0, ptStart) + friendlyName + path.substring(ptEnd + 1); ptStart = path.indexOf("<pt_", ptStart); } else { ptStart = path.indexOf("<pt_", ptStart + 1); } } return path; } static getCoreBaseFromName(name) { const nameW = name.trim(); let firstPeriod = nameW.indexOf("."); if (firstPeriod < 0) { return name; } return nameW.substring(0, firstPeriod); } static getTypeFromName(name) { const nameW = name.trim().toLowerCase(); const lastPeriod = nameW.lastIndexOf("."); if (lastPeriod < 0) { return ""; } return nameW.substring(lastPeriod + 1, nameW.length); } static async folderContentsEqual(folderA, folderB, excludingFilesOrFolders, whitespaceAgnostic, ignoreAttributes, volatilePatterns) { if (folderA === undefined && folderB === undefined) { return { result: true, reason: "Both folders are undefined." }; } if (folderA === undefined) { return { result: false, reason: "First folder is undefined." }; } if (folderB === undefined) { return { result: false, reason: "Second folder is undefined." }; } await folderA.load(false); await folderB.load(false); if (folderA.fileCount !== folderB.fileCount) { return { result: false, reason: "Folder '" + folderA.fullPath + "' has " + folderA.fileCount + " files; folder '" + folderB.fullPath + "' has " + folderB.fileCount + " files.", }; } for (const fileName in folderA.files) { const fileA = folderA.files[fileName]; const fileB = folderB.files[fileName]; if (fileA === undefined) { return { result: false, reason: "Unexpected file '" + fileName + "' undefined." }; } if (fileB === undefined) { return { result: false, reason: "File '" + fileName + "' does not exist in '" + folderB.fullPath + "'" }; } let processFile = true; if (excludingFilesOrFolders) { if (excludingFilesOrFolders.includes(fileA.name)) { processFile = false; } for (const excludeExt of excludingFilesOrFolders) { if (fileA.name.toLowerCase().endsWith(excludeExt.toLowerCase())) { processFile = false; break; } } } if (processFile) { const result = await StorageUtilities.fileContentsEqual(fileA, fileB, whitespaceAgnostic, ignoreAttributes, volatilePatterns); if (!result) { return { result: false, reason: "File '" + fileA.fullPath + "' (size: " + fileA.content?.length + (fileA.isBinary ? "B" : "C") + ") contents does not match '" + fileB.fullPath + "' (size: " + fileB.content?.length + (fileB.isBinary ? "B" : "C") + ")", }; } } } for (const folderName in folderA.folders) { const childFolderA = folderA.folders[folderName]; const childFolderB = folderB.folders[folderName]; if (childFolderA === undefined) { return { result: false, reason: "Unexpected folder undefined. " }; } // Check if this folder should be excluded let excludeFolder = false; if (excludingFilesOrFolders) { if (excludingFilesOrFolders.includes(folderName)) { excludeFolder = true; } } if (excludeFolder) { continue; } if (childFolderB === undefined) { return { result: false, reason: "Folder '" + folderName + "' does not exist in '" + folderB.fullPath + "'" }; } const result = await StorageUtilities.folderContentsEqual(childFolderA, childFolderB, excludingFilesOrFolders, whitespaceAgnostic, ignoreAttributes, volatilePatterns); if (!result.result) { return result; } } return { result: true, reason: "Folders are equal" }; } static async fileContentsEqual(fileA, fileB, whitespaceAgnostic, ignoreAttributes, volatilePatterns) { if (fileA === undefined && fileB === undefined) { return true; } if (fileA === undefined) { return false; } if (fileB === undefined) { return false; } const fileAExists = await fileA.exists(); if (!fileAExists) { return false; } const fileBExists = await fileB.exists(); if (fileAExists && !fileBExists) { return false; } await fileA.loadContent(false); await fileB.loadContent(false); if (fileA.content === undefined && fileB.content === undefined) { return true; } const extA = StorageUtilities.getTypeFromName(fileA.name); const extB = StorageUtilities.getTypeFromName(fileB.name); let contentA = fileA.content; let contentB = fileB.content; if (contentA === null && contentB === null) { return true; } if (contentA === null || contentB === null) { return false; } if (extA === "json" && extB === "json" && typeof contentA === "string" && typeof contentB === "string") { return this.jsonContentsAreEqualIgnoreWhitespace(contentA, contentB, ignoreAttributes, volatilePatterns); } else if (whitespaceAgnostic) { return StorageUtilities.contentsAreEqualIgnoreWhitespace(contentA, contentB, volatilePatterns); } return StorageUtilities.contentsAreEqual(contentA, contentB); } static jsonContentsAreEqualIgnoreWhitespace(contentA, contentB, ignoreAttributes, volatilePatterns) { try { let objA = JSON.parse(contentA); let objB = JSON.parse(contentB); if (ignoreAttributes && objA) { objA = StorageUtilities.removeAttributesFromObject(objA, ignoreAttributes); } if (ignoreAttributes && objB) { objB = StorageUtilities.removeAttributesFromObject(objB, ignoreAttributes); } // Apply volatile patterns to normalize dynamic content in strings if (volatilePatterns) { objA = StorageUtilities.applyVolatilePatternsToObject(objA, volatilePatterns); objB = StorageUtilities.applyVolatilePatternsToObject(objB, volatilePatterns); } return Utilities_1.default.consistentStringifyTrimmed(objA) === Utilities_1.default.consistentStringifyTrimmed(objB); } catch (e) { Log_1.default.debug("Error parsing JSON for comparison: " + e); } if (ignoreAttributes && typeof contentA === "string" && typeof contentB === "string") { for (const ignoreLine of ignoreAttributes) { contentA = Utilities_1.default.stripLinesContaining(contentA, '"' + ignoreLine + '":'); contentB = Utilities_1.default.stripLinesContaining(contentB, "'" + ignoreLine + "':"); } } // Apply volatile patterns to raw content as fallback if (volatilePatterns && typeof contentA === "string" && typeof contentB === "string") { for (const { pattern, replacement } of volatilePatterns) { contentA = contentA.replace(pattern, replacement); contentB = contentB.replace(pattern, replacement); } } contentA = this.stripWhitespace(contentA); contentB = this.stripWhitespace(contentB); return contentA === contentB; } /** * Recursively applies volatile patterns to normalize dynamic content within string values * @param obj The object to process * @param patterns Array of regex patterns and their replacements * @returns A new object with patterns applied to string values */ static applyVolatilePatternsToObject(obj, patterns) { if (obj === null || obj === undefined) { return obj; } // Handle strings - apply all patterns if (typeof obj === "string") { let result = obj; for (const { pattern, replacement } of patterns) { result = result.replace(pattern, replacement); } return result; } // Handle arrays if (Array.isArray(obj)) { return obj.map((item) => this.applyVolatilePatternsToObject(item, patterns)); } // Handle objects if (typeof obj === "object") { const result = {}; for (const key in obj) { if (obj.hasOwnProperty(key)) { result[key] = this.applyVolatilePatternsToObject(obj[key], patterns); } } return result; } // Handle other primitives return obj; } /** * Recursively sets attributes to undefined in a JSON object if they match any of the provided attribute names * @param obj The object to process * @param attributeNames Array of attribute names to set to undefined * @returns A new object with specified attributes set to undefined */ static removeAttributesFromObject(obj, attributeNames) { if (obj === null || obj === undefined) { return obj; } // Handle arrays if (Array.isArray(obj)) { return obj.map((item) => this.removeAttributesFromObject(item, attributeNames)); } // Handle objects if (typeof obj === "object") { const result = {}; for (const key in obj) { if (obj.hasOwnProperty(key)) { if (attributeNames.includes(key)) { // Skip this key entirely (don't add to result) continue; } else { result[key] = this.removeAttributesFromObject(obj[key], attributeNames); } } } return result; } // Handle primitives return obj; } static stripWhitespace(content) { content = content.trim(); content = content.replace(/ /gi, ""); content = content.replace(/\r/gi, ""); content = content.replace(/\n/gi, ""); content = content.replace(/\t/gi, ""); return content; } static contentsAreEqual(contentA, contentB) { if (contentA === null && contentB === null) { return true; } if (typeof contentA === "string" && typeof contentB === "string") { return contentA === contentB; } if (contentA instanceof Uint8Array && contentB instanceof Uint8Array) { return Utilities_1.default.uint8ArraysAreEqual(contentA, contentB); } return false; } static contentsAreEqualIgnoreWhitespace(contentA, contentB, volatilePatterns) { if (contentA === null && contentB === null) { return true; } if (typeof contentA === "string" && typeof contentB === "string") { let strA = contentA; let strB = contentB; // Apply volatile patterns to normalize dynamic content if (volatilePatterns) { for (const { pattern, replacement } of volatilePatterns) { strA = strA.replace(pattern, replacement); strB = strB.replace(pattern, replacement); } } return this.stripWhitespace(strA) === this.stripWhitespace(strB); } if (contentA instanceof Uint8Array && contentB instanceof Uint8Array) { return Utilities_1.default.uint8ArraysAreEqual(contentA, contentB); } return false; } static async getDifferences(original, updated, includeDeletions, matchSingleChildFolders) { // matchSingleChildFolders: for project template starters, where they have a structure like: // resource_packs/template_name // that gets renamed to // resource_packs/mikesfooproject // then -- if there is one folder in the original and one folder in the updated, // we want to match them up irrespective of the name const data = new DifferenceSet_1.default(); await this.addDifferences(data, original, updated, includeDeletions, matchSingleChildFolders); return data; } static getFirstFile(folder) { for (const fileName in folder.files) { const file = folder.files[fileName]; if (file && !file.canIgnore) { return file; } } for (const folderName in folder.folders) { const subFolder = folder.folders[folderName]; if (subFolder) { const file = this.getFirstFile(subFolder); if (file && !file.canIgnore) { return file; } } } return undefined; } static async addDifferences(differences, original, updated, includeDeletions, matchSingleFolders) { await original.load(false); await updated.load(false); let result = IFolderDifference_1.FolderDifferenceType.none; // get a list of existing files and folders in the target const updatedFilesToConsider = {}; const updatedFoldersToConsider = {}; for (const updatedFileName in updated.files) { if (BasicValidators_1.BasicValidators.isFileNameOKForSharing(updatedFileName)) { updatedFilesToConsider[updatedFileName] = true; } } for (const updatedFolderName in updated.folders) { if (BasicValidators_1.BasicValidators.isFolderNameOKForSharing(updatedFolderName)) { updatedFoldersToConsider[updatedFolderName] = true; } } for (const originalFileName in original.files) { if (BasicValidators_1.BasicValidators.isFileNameOKForSharing(originalFileName)) { const originalFile = original.files[originalFileName]; if (originalFile !== undefined) { updatedFilesToConsider[originalFileName] = false; if (updated.fileExists(originalFileName)) { const updatedFile = updated.files[originalFileName]; const areEqual = await StorageUtilities.fileContentsEqual(originalFile, updatedFile); if (!areEqual) { if ((result & IFolderDifference_1.FolderDifferenceType.fileContentsDifferent) === 0) { result += IFolderDifference_1.FolderDifferenceType.fileContentsDifferent; } differences.fileDifferences.push({ type: IFileDifference_1.FileDifferenceType.contentsDifferent, path: originalFile.storageRelativePath, original: originalFile, updated: updatedFile, }); } } else if (includeDeletions) { if ((result & IFolderDifference_1.FolderDifferenceType.fileListDifferent) === 0) { result += IFolderDifference_1.FolderDifferenceType.fileListDifferent; } differences.fileDifferences.push({ type: IFileDifference_1.FileDifferenceType.fileDeleted, path: originalFile.storageRelativePath, original: originalFile, }); } } } } for (const originalFolderName in original.folders) { if (BasicValidators_1.BasicValidators.isFolderNameOKForSharing(originalFolderName)) { const originalChildFolder = original.folders[originalFolderName]; if (originalChildFolder !== undefined) { updatedFoldersToConsider[originalFolderName] = false; if (updated.folderExists(originalFolderName) || (matchSingleFolders && updated.folderCount === 1 && original.folderCount === 1)) { let updatedChildFolder = updated.folders[originalFolderName]; if (matchSingleFolders && updated.folderCount && original.folderCount && !updatedChildFolder) { updatedChildFolder = updated.getFolderByIndex(0); } if (updatedChildFolder !== undefined) { updatedFoldersToConsider[updatedChildFolder.name] = false; const childResult = await StorageUtilities.addDifferences(differences, originalChildFolder, updatedChildFolder, includeDeletions, matchSingleFolders); if (childResult !== IFolderDifference_1.FolderDifferenceType.none) { result = result | childResult; } } } else if (includeDeletions) { if ((result & IFolderDifference_1.FolderDifferenceType.folderDeleted) === 0) { result += IFolderDifference_1.FolderDifferenceType.folderDeleted; } this.addDifferencesAsFolderDelete(differences, originalChildFolder); } } } } for (const updatedFileName in updatedFilesToConsider) { if (updatedFilesToConsider[updatedFileName] === true) { const updatedFile = updated.files[updatedFileName]; if (updatedFile !== undefined) { if ((result & IFolderDifference_1.FolderDifferenceType.fileListDifferent) === 0) {