@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
1,290 lines (1,289 loc) • 75.5 kB
JavaScript
"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) {