diff --git a/extensions/extensions.json b/extensions/extensions.json index 442f300d59..8ad15b3a03 100644 --- a/extensions/extensions.json +++ b/extensions/extensions.json @@ -79,6 +79,7 @@ "CST1229/zip", "CST1229/images", "TheShovel/LZ-String", + "kx1bx1/rubyfs", "0832/rxFS2", "NexusKitten/sgrab", "NOname-awa/graphics2d", diff --git a/extensions/kx1bx1/rubyfs.js b/extensions/kx1bx1/rubyfs.js new file mode 100644 index 0000000000..6b01967bad --- /dev/null +++ b/extensions/kx1bx1/rubyfs.js @@ -0,0 +1,1626 @@ +// Name: RubyFS +// ID: rubyFS +// Description: A structured, in-memory file system for Scratch projects (Previously LiFS/Lithium FS). +// By: kx1bx1 +// Original: 0832 +// License: MIT + +// Totally did NOT use Find & Replace... + +(function (Scratch) { + "use strict"; + + const defaultPerms = { + create: true, + delete: true, + see: true, + read: true, + write: true, + control: true, + }; + + const extensionVersion = "1.0.5"; + + class RubyFS { + constructor() { + this.fs = new Map(); + this.RubyFSLogEnabled = false; + this.lastError = ""; + this.readActivity = false; + this.writeActivity = false; + + this._log("Initializing RubyFS..."); + this._internalClean(); + } + + getInfo() { + return { + id: "rubyFS", + name: Scratch.translate("RubyFS"), + + color1: "#d52246", + color2: "#a61734", + color3: "#7f1026", + + blocks: [ + { + opcode: "start", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("create [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + }, + }, + { + opcode: "folder", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set [STR] to [STR2]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + STR2: { + type: Scratch.ArgumentType.STRING, + defaultValue: "RubyFS is good!", + }, + }, + }, + { + opcode: "open", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("open [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + }, + }, + { + opcode: "del", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("delete [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + }, + }, + { + opcode: "list", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("list [TYPE] under [STR]"), + arguments: { + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: "LIST_TYPE_MENU", + defaultValue: "all", + }, + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/", + }, + }, + }, + "---", + + { + opcode: "copy", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("copy [STR] to [STR2]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + STR2: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/copy_of_example.txt", + }, + }, + }, + { + opcode: "sync", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("rename [STR] to [STR2]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + STR2: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/new_example.txt", + }, + }, + }, + { + opcode: "exists", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("does [STR] exist?"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + }, + }, + { + opcode: "isFile", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("is [STR] a file?"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + }, + }, + { + opcode: "isDir", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("is [STR] a directory?"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/", + }, + }, + }, + { + opcode: "fileName", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("file name of [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + }, + }, + { + opcode: "dirName", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("directory of [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + }, + }, + + { + opcode: "dateCreated", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("date created of [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + }, + }, + { + opcode: "dateModified", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("date modified of [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + }, + }, + { + opcode: "dateAccessed", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("date accessed of [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + }, + }, + "---", + + { + opcode: "setLimit", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate( + "set size limit for [DIR] to [BYTES] bytes" + ), + arguments: { + DIR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/", + }, + BYTES: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8192, + }, + }, + }, + { + opcode: "removeLimit", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("remove size limit for [DIR]"), + arguments: { + DIR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/", + }, + }, + }, + { + opcode: "getLimit", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("size limit of [DIR] (bytes)"), + arguments: { + DIR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/", + }, + }, + }, + { + opcode: "getSize", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("current size of [DIR] (bytes)"), + arguments: { + DIR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/", + }, + }, + }, + { + opcode: "setPerm", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("[ACTION] [PERM] permission for [STR]"), + arguments: { + ACTION: { + type: Scratch.ArgumentType.STRING, + menu: "PERM_ACTION_MENU", + defaultValue: "remove", + }, + PERM: { + type: Scratch.ArgumentType.STRING, + menu: "PERM_TYPE_MENU", + defaultValue: "write", + }, + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/", + }, + }, + }, + { + opcode: "listPerms", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("list permissions for [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/", + }, + }, + }, + "---", + + { + opcode: "clean", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("clear the file system"), + arguments: {}, + }, + { + opcode: "in", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("import file system from [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"version":"1.0.5","fs":{}}', + }, + }, + }, + { + opcode: "out", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("export file system"), + arguments: {}, + }, + { + opcode: "wasRead", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("was read?"), + }, + { + opcode: "wasWritten", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("was written?"), + }, + { + opcode: "getLastError", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("last error"), + }, + { + opcode: "toggleLogging", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("turn [STATE] console logging"), + arguments: { + STATE: { + type: Scratch.ArgumentType.STRING, + menu: "LOG_STATE_MENU", + defaultValue: "on", + }, + }, + }, + { + opcode: "getVersion", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("version"), + }, + ], + menus: { + LIST_TYPE_MENU: { + acceptReporters: true, + items: [ + { + text: "all", + value: "all", + }, + { + text: "files", + value: "files", + }, + { + text: "directories", + value: "directories", + }, + ], + }, + PERM_ACTION_MENU: { + acceptReporters: true, + items: [ + { + text: "add", + value: "add", + }, + { + text: "remove", + value: "remove", + }, + ], + }, + PERM_TYPE_MENU: { + acceptReporters: true, + items: [ + { + text: "create", + value: "create", + }, + { + text: "delete", + value: "delete", + }, + { + text: "see", + value: "see", + }, + { + text: "read", + value: "read", + }, + { + text: "write", + value: "write", + }, + { + text: "control", + value: "control", + }, + ], + }, + LOG_STATE_MENU: { + acceptReporters: true, + items: [ + { + text: "on", + value: "on", + }, + { + text: "off", + value: "off", + }, + ], + }, + }, + }; + } + + _log(message, ...args) { + if (this.RubyFSLogEnabled) { + console.log(`[RubyFS] ${message}`, ...args); + } + } + + _warn(message, ...args) { + if (this.RubyFSLogEnabled) { + console.warn(`[RubyFS] ${message}`, ...args); + } + } + + _setError(message, ...args) { + this._warn(message, ...args); + this.lastError = message; + } + + _normalizePath(path) { + if (typeof path !== "string" || path.length === 0) { + return "/"; + } + + const hadTrailingSlash = path.length > 1 && path.endsWith("/"); + + if (path[0] !== "/") { + path = "/" + path; + } + + const segments = path.split("/"); + const newSegments = []; + + for (const segment of segments) { + if (segment === "" || segment === ".") { + continue; + } + + if (segment === "..") { + if (newSegments.length > 0) { + newSegments.pop(); + } + } else { + newSegments.push(segment); + } + } + + let newPath = "/" + newSegments.join("/"); + + if (newPath === "/") { + return "/"; + } + + if (hadTrailingSlash) { + newPath += "/"; + } + + return newPath; + } + + _isPathDir(path) { + return path === "/" || path.endsWith("/"); + } + + _internalDirName(path) { + if (path === "/") { + return "/"; + } + + let procPath = this._isPathDir(path) + ? path.substring(0, path.length - 1) + : path; + + const lastSlash = procPath.lastIndexOf("/"); + if (lastSlash === 0) { + return "/"; + } + if (lastSlash === -1) { + return "/"; + } + + return procPath.substring(0, lastSlash + 1); + } + + _getStringSize(str) { + if (str === null || str === undefined) { + return 0; + } + + let length = 0; + for (let i = 0; i < str.length; i++) { + const charCode = str.charCodeAt(i); + if (charCode < 0x0080) { + length += 1; + } else if (charCode < 0x0800) { + length += 2; + } else if (charCode < 0xd800 || charCode > 0xdfff) { + length += 3; + } else { + length += 4; + i++; + } + } + return length; + } + + _getDirectorySize(dirPath) { + let totalSize = 0; + + for (const [itemPath, entry] of this.fs.entries()) { + if ( + !this._isPathDir(itemPath) && + itemPath.startsWith(dirPath) && + dirPath !== itemPath + ) { + totalSize += this._getStringSize(entry.content); + } + } + return totalSize; + } + + _canAccommodateChange(filePath, deltaSize) { + if (deltaSize <= 0) { + return true; + } + + let currentDir = this._internalDirName(filePath); + this._log(`Checking size change of ${deltaSize} bytes for ${filePath}`); + + while (true) { + const entry = this.fs.get(currentDir); + if (!entry) { + this._warn(`Size check: Could not find parent dir ${currentDir}`); + break; + } + + const limit = entry.limit; + if (limit !== -1) { + const currentSize = this._getDirectorySize(currentDir); + if (currentSize + deltaSize > limit) { + this._setError( + `Size limit exceeded for ${currentDir}: ${currentSize} + ${deltaSize} > ${limit}` + ); + return false; + } + } + + if (currentDir === "/") { + break; + } + currentDir = this._internalDirName(currentDir); + } + + return true; + } + + _internalCreate(path, content, parentDir) { + if (this.fs.has(path)) { + this._log("InternalCreate failed: Path already exists", path); + + return false; + } + + if (!this.hasPermission(parentDir, "create")) { + this._setError(`Create failed: No 'create' permission in ${parentDir}`); + return false; + } + + const deltaSize = this._getStringSize(content); + if (!this._canAccommodateChange(path, deltaSize)) { + this._log("InternalCreate failed: Size limit exceeded"); + return false; + } + + let permsToInherit; + const parentEntry = this.fs.get(parentDir); + + if (parentEntry) { + permsToInherit = parentEntry.perms; + } else if (parentDir === "/") { + permsToInherit = this.fs.get("/").perms; + } else { + this._warn( + "InternalCreate: Parent not found, using default perms", + parentDir + ); + permsToInherit = defaultPerms; + } + + const now = Date.now(); + this.fs.set(path, { + content: content, + perms: JSON.parse(JSON.stringify(permsToInherit)), + limit: -1, + created: now, + modified: now, + accessed: now, + }); + this.writeActivity = true; + this._log("InternalCreate successful:", path); + return true; + } + + hasPermission(path, action) { + const normPath = this._normalizePath(path); + this._log("Checking permission:", action, "on", normPath); + + const entry = this.fs.get(normPath); + + if (entry) { + const result = entry.perms[action]; + this._log("Permission result:", result); + return result; + } + + if (action === "create") { + const parentDir = this._internalDirName(normPath); + const parentEntry = this.fs.get(parentDir); + + if (!parentEntry) { + const result = parentDir === "/"; + this._log("Permission result (parent check, root):", result); + return result; + } + const result = parentEntry.perms.create; + this._log("Permission result (parent check):", result); + return result; + } + + this._log("Permission result (default fail):", false); + return false; + } + + _internalClean() { + this._log("Internal: Clearing file system..."); + const now = Date.now(); + this.fs.clear(); + this.fs.set("/", { + content: null, + perms: JSON.parse(JSON.stringify(defaultPerms)), + limit: -1, + created: now, + modified: now, + accessed: now, + }); + this._log("Internal: File system reset to root."); + this.writeActivity = true; + } + + clean() { + this.lastError = ""; + this._log("Block: clean"); + if (!this.hasPermission("/", "delete")) { + return this._setError("Clean failed: No 'delete' permission on /"); + } + this._internalClean(); + } + + sync({ STR, STR2 }) { + this.lastError = ""; + const path1 = this._normalizePath(STR); + const path2 = this._normalizePath(STR2); + this._log("Block: rename", path1, "to", path2); + + if (!this.hasPermission(path1, "delete")) { + return this._setError( + `Rename failed: No 'delete' permission on ${path1}` + ); + } + if (this.fs.has(path2)) { + return this._setError( + `Rename failed: Destination ${path2} already exists` + ); + } + if (!this.hasPermission(path2, "create")) { + return this._setError( + `Rename failed: No 'create' permission for ${path2}` + ); + } + + const entry = this.fs.get(path1); + if (!entry) { + return this._setError(`Rename failed: Source ${path1} not found`); + } + + const isDir = this._isPathDir(path1); + let deltaSize = 0; + if (isDir) { + deltaSize = this._getDirectorySize(path1); + } else { + deltaSize = this._getStringSize(entry.content); + } + + if (!this._canAccommodateChange(path2, deltaSize)) { + return; + } + + const now = Date.now(); + + if (isDir) { + this._log("Renaming directory and children..."); + + const path1Prefix = path1.endsWith("/") ? path1 : path1 + "/"; + + const toRename = []; + for (const [key, value] of this.fs.entries()) { + if (key === path1 || key.startsWith(path1Prefix)) { + toRename.push({ + oldKey: key, + value: value, + }); + } + } + + const path1Length = path1.length; + for (const item of toRename) { + const remainder = item.oldKey.substring(path1Length); + const newChildPath = path2 + remainder; + + if (item.oldKey === path1) { + item.value.modified = now; + item.value.accessed = now; + } + + this.fs.set(newChildPath, item.value); + this.fs.delete(item.oldKey); + + this._log(`Renaming: ${item.oldKey} to ${newChildPath}`); + } + } else { + this._log("Renaming single file..."); + entry.modified = now; + entry.accessed = now; + this.fs.set(path2, entry); + this.fs.delete(path1); + this._log("Rename successful"); + } + this.writeActivity = true; + } + + copy({ STR, STR2 }) { + this.lastError = ""; + const path1 = this._normalizePath(STR); + const path2 = this._normalizePath(STR2); + this._log("Block: copy", path1, "to", path2); + + const entry = this.fs.get(path1); + if (!entry) { + return this._setError(`Copy failed: Source ${path1} not found`); + } + + if (!entry.perms.read) { + return this._setError(`Copy failed: No 'read' permission on ${path1}`); + } + if (this.fs.has(path2)) { + return this._setError( + `Copy failed: Destination ${path2} already exists` + ); + } + if (!this.hasPermission(path2, "create")) { + return this._setError( + `Copy failed: No 'create' permission for ${path2}` + ); + } + + this.readActivity = true; + const now = Date.now(); + entry.accessed = now; + + if (this._isPathDir(path1)) { + const toCopy = []; + let totalDeltaSize = 0; + const path1Length = path1.length; + + const path1Prefix = path1.endsWith("/") ? path1 : path1 + "/"; + + for (const [key, value] of this.fs.entries()) { + if (key === path1 || key.startsWith(path1Prefix)) { + if (!this._isPathDir(key)) { + totalDeltaSize += this._getStringSize(value.content); + } + toCopy.push({ + key, + value, + }); + } + } + + if (!this._canAccommodateChange(path2, totalDeltaSize)) { + return; + } + + for (const item of toCopy) { + const remainder = item.key.substring(path1Length); + const newChildPath = path2 + remainder; + + this.fs.set(newChildPath, { + content: + item.value.content === null ? null : "" + item.value.content, + perms: JSON.parse(JSON.stringify(item.value.perms)), + limit: item.value.limit, + created: item.value.created, + modified: item.value.modified, + accessed: now, + }); + this._log(`Copied ${item.key} to ${newChildPath}`); + } + this.writeActivity = true; + this._log("Recursive copy successful"); + } else { + const content = "" + entry.content; + const deltaSize = this._getStringSize(content); + if (!this._canAccommodateChange(path2, deltaSize)) { + return; + } + + const destParentDir = this._internalDirName(path2); + const destParentEntry = this.fs.get(destParentDir); + let permsToInherit = defaultPerms; + + if (destParentEntry) { + permsToInherit = destParentEntry.perms; + } else if (destParentDir === "/") { + permsToInherit = this.fs.get("/").perms; + } else { + this._log( + `Copy: Could not find parent "${destParentDir}", using default perms.` + ); + } + + this.fs.set(path2, { + content: content, + perms: JSON.parse(JSON.stringify(permsToInherit)), + limit: -1, + created: now, + modified: now, + accessed: now, + }); + this.writeActivity = true; + this._log("Copy successful"); + } + } + + start({ STR }) { + this.lastError = ""; + const path = this._normalizePath(STR); + this._log("Block: create", path); + + if (path === "/") { + return this._setError( + "Create failed: Cannot create root directory '/'" + ); + } + + if (this.fs.has(path)) { + return this._setError(`Create failed: ${path} already exists`); + } + + const parentDir = this._internalDirName(path); + if (parentDir !== "/" && !this.fs.has(parentDir)) { + this._log("Creating parent directory:", parentDir); + + if (!this.hasPermission(parentDir, "create")) { + return this._setError( + `Create failed: No 'create' permission in ${this._internalDirName(parentDir)}, aborting recursive create.` + ); + } + + this.start({ + STR: parentDir, + }); + + if (this.lastError) { + this._log( + "Create failed: Parent creation failed (recursive call failed)." + ); + + return; + } + if (!this.fs.has(parentDir)) { + return this._setError( + "Create failed: Parent creation failed, aborting." + ); + } + } + + const ok = this._internalCreate( + path, + this._isPathDir(path) ? null : "", + parentDir + ); + + if (!ok) { + this._log("Create failed: _internalCreate returned false."); + + if (!this.lastError) { + this._setError( + `Create failed: An internal error occurred for ${path}` + ); + } + return; + } + } + + open({ STR }) { + const path = this._normalizePath(STR); + this._log("Block: open", path); + + const entry = this.fs.get(path); + this.readActivity = true; + + if (!entry) { + this._log("Result: (Not found)", ""); + return ""; + } + if (this._isPathDir(path)) { + this._log("Result: (Is a directory)", ""); + return ""; + } + + if (!entry.perms.read) { + this._warn(`Read permission denied for "${path}"`); + return ""; + } + + entry.accessed = Date.now(); + const content = entry.content; + this._log("Result:", content); + return content; + } + + del({ STR }) { + this.lastError = ""; + const path = this._normalizePath(STR); + this._log("Block: delete", path); + + if (!this.hasPermission(path, "delete")) { + return this._setError( + `Delete failed: No 'delete' permission on ${path}` + ); + } + + const isDir = this._isPathDir(path); + + const pathPrefix = path.endsWith("/") ? path : path + "/"; + + const toDelete = []; + for (const currentPath of this.fs.keys()) { + if (isDir) { + if (currentPath === path || currentPath.startsWith(pathPrefix)) { + toDelete.push(currentPath); + } + } else { + if (currentPath === path) { + toDelete.push(currentPath); + break; + } + } + } + + for (const key of toDelete) { + this.fs.delete(key); + this._log("Deleted:", key); + } + + this.writeActivity = true; + this._log("Delete complete"); + } + + folder({ STR, STR2 }) { + this.lastError = ""; + const path = this._normalizePath(STR); + this._log("Block: set", path, "to", STR2); + + let entry = this.fs.get(path); + + if (!entry) { + this._log("Set: File not found, attempting to create..."); + this.start({ + STR: path, + }); + entry = this.fs.get(path); + if (!entry) { + this._log("Set failed: Creation also failed"); + + return; + } + } + + if (this._isPathDir(path)) { + return this._setError("Set failed: Cannot set content of a directory"); + } + if (!entry.perms.write) { + return this._setError(`Set failed: No 'write' permission on ${path}`); + } + + const oldContent = entry.content || ""; + const deltaSize = + this._getStringSize(STR2) - this._getStringSize(oldContent); + + if (!this._canAccommodateChange(path, deltaSize)) { + return; + } + + entry.content = STR2; + const now = Date.now(); + entry.modified = now; + entry.accessed = now; + this.writeActivity = true; + this._log("Set successful"); + } + + list({ TYPE, STR }) { + let path = this._normalizePath(STR); + if (!this._isPathDir(path)) { + path += "/"; + } + + this._log("Block: list", TYPE, "under", path); + this.readActivity = true; + const emptyList = []; + + const entry = this.fs.get(path); + if (!entry) { + this._log("List failed: Directory not found."); + return emptyList; + } + + if (!this.hasPermission(path, "see")) { + this._log("List failed: No see permission on directory"); + return emptyList; + } + + entry.accessed = Date.now(); + + let children = new Set(); + const pathLen = path.length; + + for (const itemPath of this.fs.keys()) { + if (itemPath === path || itemPath === "/") continue; + + if (itemPath.startsWith(path)) { + let remainder = itemPath.substring(pathLen); + let nextSlash = remainder.indexOf("/"); + let childName = ""; + let isDir = false; + + if (nextSlash === -1) { + childName = remainder; + isDir = false; + } else { + childName = remainder.substring(0, nextSlash + 1); + isDir = true; + } + + if (childName === "") continue; + + const childPath = `${path}${childName}`; + if (!this.hasPermission(childPath, "see")) { + this._log("List: Skipping item (no see perm):", childPath); + continue; + } + + if (TYPE === "all") children.add(childName); + else if (TYPE === "files" && !isDir) children.add(childName); + else if (TYPE === "directories" && isDir) children.add(childName); + } + } + + const childrenArray = Array.from(children); + this._log("List result (raw):", childrenArray); + return childrenArray; + } + + in({ STR }) { + this.lastError = ""; + this._log("Block: import"); + if (!this.hasPermission("/", "delete")) { + return this._setError("Import failed: No 'delete' permission on /"); + } + try { + const data = JSON.parse(STR); + + const version = data ? data.version : null; + if (!version) { + return this._setError( + "Import failed: Data invalid or missing version." + ); + } + + let needsMigration = false; + + if (version === "1.0.5") { + if ( + !data.fs || + typeof data.fs !== "object" || + Array.isArray(data.fs) + ) { + return this._setError( + "Import failed: v1.0.5 data is corrupt (missing 'fs' object)." + ); + } + } else if ( + version === "1.0.4" || + version === "1.0.3" || + version === "1.0.2" + ) { + this._log(`Import: Migrating v${version} save...`); + needsMigration = true; + if (!Array.isArray(data.sl)) { + this._log(`... adding 'sl' array.`); + data.sl = new Array(data.sy.length).fill(-1); + } + if ( + !Array.isArray(data.fi) || + !Array.isArray(data.sy) || + !Array.isArray(data.pm) || + !Array.isArray(data.sl) || + data.fi.length !== data.sy.length || + data.fi.length !== data.pm.length || + data.fi.length !== data.sl.length || + data.sy.indexOf("/") === -1 + ) { + return this._setError( + "Import failed: Old version data arrays are corrupt or mismatched." + ); + } + + const now = Date.now(); + data.created = new Array(data.sy.length).fill(now); + data.modified = new Array(data.sy.length).fill(now); + data.accessed = new Array(data.sy.length).fill(now); + } else { + return this._setError( + `Import failed: Incompatible version "${version}". Expected "${extensionVersion}" or older.` + ); + } + + if (needsMigration) { + this.fs.clear(); + for (let i = 0; i < data.sy.length; i++) { + const perm = data.pm[i]; + const limit = data.sl[i]; + + if ( + typeof data.sy[i] !== "string" || + typeof perm !== "object" || + perm === null || + Array.isArray(perm) || + typeof limit !== "number" || + typeof perm.create !== "boolean" || + typeof perm.delete !== "boolean" || + typeof perm.see !== "boolean" || + typeof perm.read !== "boolean" || + typeof perm.write !== "boolean" || + typeof perm.control !== "boolean" + ) { + this._setError( + "Import failed: Corrupt data found in legacy filesystem entries." + ); + this._internalClean(); + return; + } + this.fs.set(data.sy[i], { + content: data.fi[i], + perms: data.pm[i], + limit: data.sl[i], + created: data.created[i], + modified: data.modified[i], + accessed: data.accessed[i], + }); + } + } else { + this.fs.clear(); + for (const path in data.fs) { + if (Object.prototype.hasOwnProperty.call(data.fs, path)) { + const entry = data.fs[path]; + + if ( + !entry || + typeof entry.perms !== "object" || + typeof entry.limit !== "number" || + typeof entry.created !== "number" || + typeof entry.modified !== "number" || + typeof entry.accessed !== "number" + ) { + this._setError( + `Import failed: Corrupt entry for path "${path}".` + ); + this._internalClean(); + return; + } + this.fs.set(path, entry); + } + } + if (!this.fs.has("/")) { + this._setError( + "Import failed: Rebuilt filesystem is missing root '/'." + ); + this._internalClean(); + return; + } + } + + this.writeActivity = true; + this._log("Import successful"); + } catch (e) { + this._setError( + `Import failed: JSON parse error. File system was not changed.` + ); + } + } + + out() { + this._log("Block: export"); + this.readActivity = true; + + const fsObject = {}; + for (const [path, entry] of this.fs.entries()) { + fsObject[path] = entry; + } + + const result = JSON.stringify({ + version: extensionVersion, + fs: fsObject, + }); + this._log("Export successful, size:", result.length); + return result; + } + + exists({ STR }) { + const path = this._normalizePath(STR); + this._log("Block: exists", path); + this.readActivity = true; + + const entry = this.fs.get(path); + if (!entry) { + this._log("Result: false (not found)"); + return false; + } + if (!entry.perms.see) { + this._log("Result: false (no see perm)"); + return false; + } + entry.accessed = Date.now(); + this._log("Result: true"); + return true; + } + + isFile({ STR }) { + const path = this._normalizePath(STR); + this._log("Block: isFile", path); + this.readActivity = true; + + const entry = this.fs.get(path); + if (!entry) { + this._log("Result: false (not found)"); + return false; + } + if (!entry.perms.see) { + this._log("Result: false (no see perm)"); + return false; + } + + entry.accessed = Date.now(); + const result = !this._isPathDir(path); + this._log("Result:", result); + return result; + } + + isDir({ STR }) { + const path = this._normalizePath(STR); + this._log("Block: isDir", path); + this.readActivity = true; + + const entry = this.fs.get(path); + if (!entry) { + this._log("Result: false (not found)"); + return false; + } + if (!entry.perms.see) { + this._log("Result: false (no see perm)"); + return false; + } + + entry.accessed = Date.now(); + const result = this._isPathDir(path); + this._log("Result:", result); + return result; + } + + setPerm({ ACTION, PERM, STR }) { + this.lastError = ""; + const path = this._normalizePath(STR); + this._log("Block: setPerm", ACTION, PERM, "for", path); + + if (!this.hasPermission(path, "control")) { + return this._setError( + `setPerm failed: No 'control' permission on ${path}` + ); + } + + const newValue = ACTION === "add"; + const isDir = this._isPathDir(path); + const now = Date.now(); + + const pathPrefix = path.endsWith("/") ? path : path + "/"; + + this._log("Applying changes..."); + for (const [currentPath, entry] of this.fs.entries()) { + if ( + (isDir && + (currentPath === path || currentPath.startsWith(pathPrefix))) || + currentPath === path + ) { + entry.perms[PERM] = newValue; + entry.modified = now; + entry.accessed = now; + this._log("Changed perm for:", currentPath); + } + } + this.writeActivity = true; + this._log("setPerm complete"); + } + + listPerms({ STR }) { + const path = this._normalizePath(STR); + this._log("Block: listPerms", path); + this.readActivity = true; + + const entry = this.fs.get(path); + if (!entry) { + this._log("Result: {} (not found)"); + return JSON.stringify({}); + } + + if (!entry.perms.see) { + this._warn(`See permission denied for "${path}"`); + return JSON.stringify({}); + } + + entry.accessed = Date.now(); + const result = JSON.stringify(entry.perms); + this._log("Result:", result); + return result; + } + + fileName({ STR }) { + const path = this._normalizePath(STR); + this._log("Block: fileName", path); + this.readActivity = true; + + if (!this.hasPermission(path, "see")) { + this._warn(`See permission denied for "${path}"`); + return ""; + } + + const entry = this.fs.get(path); + if (entry) entry.accessed = Date.now(); + + if (path === "/") { + this._log("Result: /"); + return "/"; + } + + let procPath = this._isPathDir(path) + ? path.substring(0, path.length - 1) + : path; + + const lastSlash = procPath.lastIndexOf("/"); + if (lastSlash === -1) { + this._log("Result (no slash):", procPath); + return procPath; + } + const file = procPath.substring(lastSlash + 1); + this._log("Result:", file); + return file; + } + + dirName({ STR }) { + const path = this._normalizePath(STR); + this._log("Block: dirName", path); + this.readActivity = true; + + if (!this.hasPermission(path, "see")) { + this._warn(`See permission denied for "${path}"`); + return ""; + } + + const entry = this.fs.get(path); + if (entry) entry.accessed = Date.now(); + + const parent = this._internalDirName(path); + this._log("Result:", parent); + return parent; + } + + toggleLogging({ STATE }) { + this.RubyFSLogEnabled = STATE === "on"; + this._log("Console logging turned", STATE); + } + + setLimit({ DIR, BYTES }) { + this.lastError = ""; + let path = this._normalizePath(DIR); + + if (!this._isPathDir(path)) { + path += "/"; + } + + this._log("Block: setLimit", path, "to", BYTES, "bytes"); + + if (!this.hasPermission(path, "control")) { + return this._setError( + `setLimit failed: No 'control' permission on ${path}` + ); + } + const entry = this.fs.get(path); + if (!entry) { + return this._setError(`setLimit failed: Directory ${path} not found`); + } + + const limitInBytes = Math.max(-1, parseFloat(BYTES) || 0); + + if (limitInBytes !== -1) { + const currentSize = this._getDirectorySize(path); + if (currentSize > limitInBytes) { + return this._setError( + `setLimit failed: New limit (${limitInBytes} B) is smaller than current directory size (${currentSize} B)` + ); + } + } + + const now = Date.now(); + entry.limit = limitInBytes; + entry.modified = now; + entry.accessed = now; + this.writeActivity = true; + this._log("setLimit successful"); + } + + removeLimit({ DIR }) { + this.lastError = ""; + let path = this._normalizePath(DIR); + + if (!this._isPathDir(path)) { + path += "/"; + } + + this._log("Block: removeLimit", path); + + if (!this.hasPermission(path, "control")) { + return this._setError( + `removeLimit failed: No 'control' permission on ${path}` + ); + } + const entry = this.fs.get(path); + if (!entry) { + return this._setError( + `removeLimit failed: Directory ${path} not found` + ); + } + + const now = Date.now(); + entry.limit = -1; + entry.modified = now; + entry.accessed = now; + this.writeActivity = true; + this._log("removeLimit successful"); + } + + getLimit({ DIR }) { + let path = this._normalizePath(DIR); + + if (!this._isPathDir(path)) { + path += "/"; + } + + this._log("Block: getLimit", path); + this.readActivity = true; + + if (!this.hasPermission(path, "see")) { + this._warn(`getLimit failed: No 'see' permission for "${path}"`); + return -1; + } + + const entry = this.fs.get(path); + if (!entry) { + this._warn(`getLimit failed: Directory ${path} not found`); + return -1; + } + + entry.accessed = Date.now(); + const limitInBytes = entry.limit; + this._log("getLimit result:", limitInBytes, "bytes"); + return limitInBytes; + } + + getSize({ DIR }) { + let path = this._normalizePath(DIR); + + if (!this._isPathDir(path)) { + path += "/"; + } + + this._log("Block: getSize", path); + this.readActivity = true; + + if (!this.hasPermission(path, "see")) { + this._warn(`getSize failed: No 'see' permission for "${path}"`); + return 0; + } + + const entry = this.fs.get(path); + if (!entry) { + this._warn(`getSize failed: Directory ${path} not found`); + return 0; + } + + entry.accessed = Date.now(); + const sizeInBytes = this._getDirectorySize(path); + this._log("getSize result:", sizeInBytes, "bytes"); + return sizeInBytes; + } + + _getTimestamp(path, type) { + this.readActivity = true; + const entry = this.fs.get(path); + if (!entry) { + this._warn(`Timestamp check failed: ${path} not found.`); + return ""; + } + if (!entry.perms.see) { + this._warn(`Timestamp check failed: No 'see' permission on ${path}.`); + return ""; + } + entry.accessed = Date.now(); + const timestamp = entry[type]; + return new Date(timestamp).toISOString(); + } + + dateCreated({ STR }) { + const path = this._normalizePath(STR); + return this._getTimestamp(path, "created"); + } + + dateModified({ STR }) { + const path = this._normalizePath(STR); + return this._getTimestamp(path, "modified"); + } + + dateAccessed({ STR }) { + const path = this._normalizePath(STR); + return this._getTimestamp(path, "accessed"); + } + + getLastError() { + return this.lastError; + } + + wasRead() { + const val = this.readActivity; + this.readActivity = false; + return val; + } + + wasWritten() { + const val = this.writeActivity; + this.writeActivity = false; + return val; + } + + getVersion() { + return extensionVersion; + } + } + + Scratch.extensions.register(/** @type {any} */ (new RubyFS())); +})(Scratch); diff --git a/images/kx1bx1/rubyfs.svg b/images/kx1bx1/rubyfs.svg new file mode 100644 index 0000000000..f5d40a4fff --- /dev/null +++ b/images/kx1bx1/rubyfs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ca9e99e66c..d660a0fcae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -289,7 +289,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -702,7 +701,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1",