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..ac5700488f --- /dev/null +++ b/extensions/kx1bx1/rubyfs.js @@ -0,0 +1,1460 @@ +// Name: RubyFS +// ID: rubyFS +// Description: A structured, in-memory file system for Scratch projects (Previously LiFS/Lithium FS). +// By: kx1bx1 +// Original: 0832 +// License: MIT + +// Version: 1.2.0 +// - New child indexing (O(1)) +// - Trash feature +// - New "empty trash" block + +(function (Scratch) { + "use strict"; + + const defaultPerms = { + create: true, + delete: true, + see: true, + read: true, + write: true, + control: true, + }; + + const extensionVersion = "1.2.0"; + + class RubyFS { + constructor() { + this.fs = new Map(); + + this.childIndex = new Map(); + + this.RubyFSLogEnabled = false; + this.lastError = ""; + this.readActivity = false; + this.writeActivity = false; + this.lastReadPath = ""; + this.lastWritePath = ""; + + this._log("Initializing RubyFS..."); + this._internalClean(); + } + + getInfo() { + return { + id: "rubyFS", + name: Scratch.translate("RubyFS"), + color1: "#d52246", + color2: "#a61734", + color3: "#7f1026", + description: Scratch.translate( + "A structured, in-memory file system. (Use 'turn on console logging' for debugging.)" + ), + blocks: [ + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Core Operations"), + }, + { + 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] as JSON"), + arguments: { + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: "LIST_TYPE_MENU", + defaultValue: "all", + }, + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/", + }, + }, + }, + + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("File & Directory Utilities"), + }, + { + 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: "emptyTrash", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("empty trash"), + }, + { + 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", + }, + }, + }, + + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Timestamp Utilities"), + }, + { + 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", + }, + }, + }, + + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Permissions & Limits"), + }, + { + 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/", + }, + }, + }, + + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Import & Export"), + }, + { + 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.2.0","fs":{}}', + }, + }, + }, + { + opcode: "out", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("export file system"), + arguments: {}, + }, + { + opcode: "exportFileBase64", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("export file [STR] as [FORMAT]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + FORMAT: { + type: Scratch.ArgumentType.STRING, + menu: "BASE64_FORMAT_MENU", + defaultValue: "base64", + }, + }, + }, + { + opcode: "importFileBase64", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("import [FORMAT] [STR] to file [STR2]"), + arguments: { + FORMAT: { + type: Scratch.ArgumentType.STRING, + menu: "BASE64_FORMAT_MENU", + defaultValue: "base64", + }, + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "UmVuZUZTIWlzZ29vZCE=", + }, + STR2: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/imported.txt", + }, + }, + }, + + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Debugging & Activity"), + }, + { + opcode: "wasRead", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("was read?"), + }, + { + opcode: "wasWritten", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("was written?"), + }, + { + opcode: "getLastReadPath", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("last path read"), + }, + { + opcode: "getLastWritePath", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("last path 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"), + }, + { + opcode: "runIntegrityTest", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("run integrity test"), + }, + ], + 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", + }, + ], + }, + BASE64_FORMAT_MENU: { + acceptReporters: true, + items: [ + { + text: "Base64 String", + value: "base64", + }, + { + text: "Data URL", + value: "data_url", + }, + ], + }, + }, + }; + } + + _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; + } + + _encodeUTF8Base64(str) { + try { + return btoa(str); + } catch (e) { + try { + return btoa( + encodeURIComponent(str).replace( + /%([0-9A-F]{2})/g, + function toSolidBytes(match, p1) { + // @ts-ignore + return String.fromCharCode("0x" + p1); + } + ) + ); + } catch (e2) { + this._setError(`Base64 Encode Error: ${e2.message}`); + return ""; + } + } + } + + _decodeUTF8Base64(base64) { + try { + return decodeURIComponent( + atob(base64) + .split("") + .map(function (c) { + return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); + }) + .join("") + ); + } catch (e) { + return atob(base64); + } + } + + _getMimeType(path) { + const extension = path.split(".").pop().toLowerCase(); + switch (extension) { + case "txt": + return "text/plain"; + case "json": + return "application/json"; + case "svg": + return "image/svg+xml"; + case "png": + return "image/png"; + case "jpg": + case "jpeg": + return "image/jpeg"; + case "gif": + return "image/gif"; + case "zip": + return "application/zip"; + case "sprite3": + return "application/x-zip-compressed"; + case "sb3": + return "application/x-zip-compressed"; + case "wav": + return "audio/wav"; + case "mp3": + return "audio/mpeg"; + default: + return "application/octet-stream"; + } + } + + _addToIndex(path) { + const parent = this._internalDirName(path); + if (!this.childIndex.has(parent)) this.childIndex.set(parent, new Set()); + this.childIndex.get(parent).add(path); + } + + _removeFromIndex(path) { + const parent = this._internalDirName(path); + if (this.childIndex.has(parent)) this.childIndex.get(parent).delete(path); + + if (this.childIndex.has(path)) this.childIndex.delete(path); + } + + _ensureTrash() { + if (!this.fs.has("/.Trash/")) { + const now = Date.now(); + this.fs.set("/.Trash/", { + content: null, + perms: JSON.parse(JSON.stringify(defaultPerms)), + limit: -1, + created: now, + modified: now, + accessed: now, + }); + this._addToIndex("/.Trash/"); + + if (!this.childIndex.has("/.Trash/")) { + this.childIndex.set("/.Trash/", new Set()); + } + } + } + + _normalizePath(path) { + if (typeof path !== "string" || !path.trim()) return null; + 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 "/"; + return procPath.substring(0, lastSlash + 1); + } + + _getStringSize(str) { + return str === null || str === undefined ? 0 : str.length; + } + + _getDirectorySize(dirPath) { + let totalSize = 0; + const stack = [dirPath]; + while (stack.length > 0) { + const currentPath = stack.pop(); + const children = this.childIndex.get(currentPath); + if (children) { + for (const child of children) { + const entry = this.fs.get(child); + if (entry) { + if (this._isPathDir(child)) stack.push(child); + else totalSize += this._getStringSize(entry.content); + } + } + } + } + return totalSize; + } + + _canAccommodateChange(filePath, deltaSize) { + if (deltaSize <= 0) return true; + let currentDir = this._internalDirName(filePath); + while (true) { + const entry = this.fs.get(currentDir); + if (entry && entry.limit !== -1) { + const currentSize = this._getDirectorySize(currentDir); + if (currentSize + deltaSize > entry.limit) { + this._setError(`Size limit exceeded for ${currentDir}`); + return false; + } + } + if (currentDir === "/") break; + currentDir = this._internalDirName(currentDir); + } + return true; + } + + _internalCreate(path, content, parentDir) { + if (this.fs.has(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 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._addToIndex(path); + + this.writeActivity = true; + this.lastWritePath = path; + return true; + } + + hasPermission(path, action) { + const normPath = this._normalizePath(path); + if (!normPath) return false; + const entry = this.fs.get(normPath); + if (entry) return entry.perms[action]; + if (action === "create") { + const parentDir = this._internalDirName(normPath); + const parentEntry = this.fs.get(parentDir); + if (!parentEntry) return parentDir === "/"; + return parentEntry.perms.create; + } + return false; + } + + _internalClean() { + this._log("Internal: Clearing file system..."); + const now = Date.now(); + this.fs.clear(); + this.childIndex.clear(); + + this.fs.set("/", { + content: null, + perms: JSON.parse(JSON.stringify(defaultPerms)), + limit: -1, + created: now, + modified: now, + accessed: now, + }); + this._ensureTrash(); + this.writeActivity = true; + this.lastWritePath = "/"; + } + + clean() { + this.lastError = ""; + 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); + if (!path1 || !path2) return this._setError("Invalid path provided."); + if (path1 === "/") + return this._setError( + "Rename failed: Root directory cannot be renamed" + ); + + if (!this.hasPermission(path1, "delete")) + return this._setError("Rename failed: No 'delete' permission"); + if (this.fs.has(path2)) + return this._setError("Rename failed: Destination exists"); + + if (this._isPathDir(path2)) { + if (this.fs.has(path2.slice(0, -1))) + return this._setError("Rename failed: File collision"); + } else { + if (this.fs.has(path2 + "/")) + return this._setError("Rename failed: Directory collision"); + } + + if (!this.hasPermission(path2, "create")) + return this._setError("Rename failed: No 'create' permission"); + + const entry = this.fs.get(path1); + if (!entry) return this._setError("Rename failed: Source 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(); + const toRename = []; + + if (isDir) { + const stack = [path1]; + while (stack.length > 0) { + const curr = stack.pop(); + toRename.push(curr); + const children = this.childIndex.get(curr); + if (children) { + for (const c of children) { + if (this._isPathDir(c)) stack.push(c); + else toRename.push(c); + } + } + } + } else { + toRename.push(path1); + } + + const path1Length = path1.length; + for (const oldKey of toRename) { + const entryVal = this.fs.get(oldKey); + if (!entryVal) continue; + const remainder = oldKey.substring(path1Length); + const newKey = path2 + remainder; + if (oldKey === path1) { + entryVal.modified = now; + entryVal.accessed = now; + } + this.fs.set(newKey, entryVal); + this.fs.delete(oldKey); + this._removeFromIndex(oldKey); + + this._addToIndex(newKey); + } + this.writeActivity = true; + this.lastWritePath = path2; + } + + copy({ STR, STR2 }) { + this.lastError = ""; + const path1 = this._normalizePath(STR); + const path2 = this._normalizePath(STR2); + if (!path1 || !path2) return this._setError("Invalid path provided."); + + const entry = this.fs.get(path1); + if (!entry) return this._setError("Copy failed: Source not found"); + if (!entry.perms.read) + return this._setError("Copy failed: No 'read' permission"); + if (this.fs.has(path2)) + return this._setError("Copy failed: Destination exists"); + if (!this.hasPermission(path2, "create")) + return this._setError("Copy failed: No 'create' permission"); + + this.readActivity = true; + this.lastReadPath = path1; + const now = Date.now(); + entry.accessed = now; + + const toCopy = []; + let totalDeltaSize = 0; + + if (this._isPathDir(path1)) { + const stack = [path1]; + while (stack.length > 0) { + const curr = stack.pop(); + const val = this.fs.get(curr); + toCopy.push({ + key: curr, + value: val, + }); + const children = this.childIndex.get(curr); + if (children) { + for (const c of children) { + if (this._isPathDir(c)) stack.push(c); + else { + const fVal = this.fs.get(c); + totalDeltaSize += this._getStringSize(fVal.content); + toCopy.push({ + key: c, + value: fVal, + }); + } + } + } + } + } else { + totalDeltaSize = this._getStringSize(entry.content); + toCopy.push({ + key: path1, + value: entry, + }); + } + + if (!this._canAccommodateChange(path2, totalDeltaSize)) return; + + const path1Length = path1.length; + for (const item of toCopy) { + const remainder = + item.key === path1 ? "" : item.key.substring(path1Length); + const newPath = path2 + remainder; + this.fs.set(newPath, { + content: item.value.content === null ? null : "" + item.value.content, + perms: JSON.parse(JSON.stringify(item.value.perms)), + limit: item.value.limit, + created: now, + modified: now, + accessed: now, + }); + this._addToIndex(newPath); + } + this.writeActivity = true; + this.lastWritePath = path2; + } + + start({ STR }) { + this.lastError = ""; + const path = this._normalizePath(STR); + if (!path) return this._setError("Invalid path provided."); + if (path === "/") + return this._setError("Create failed: Cannot create root"); + if (this.fs.has(path)) + return this._setError("Create failed: Path exists"); + + if (this._isPathDir(path)) { + if (this.fs.has(path.slice(0, -1))) + return this._setError("Create failed: File collision"); + } else { + if (this.fs.has(path + "/")) + return this._setError("Create failed: Directory collision"); + } + + const parentDir = this._internalDirName(path); + if (parentDir !== "/" && !this.fs.has(parentDir)) { + if (!this.hasPermission(parentDir, "create")) + return this._setError("Create failed: No permission on parent"); + this.start({ + STR: parentDir, + }); + if (this.lastError) return; + } + const ok = this._internalCreate( + path, + this._isPathDir(path) ? null : "", + parentDir + ); + if (!ok && !this.lastError) + this._setError("Create failed: Internal error"); + } + + open({ STR }) { + this.lastError = ""; + const path = this._normalizePath(STR); + if (!path) return this._setError("Invalid path"); + const entry = this.fs.get(path); + if (!entry) return this._setError("Open failed: Not found"); + if (!entry.perms.see) return this._setError("Open failed: Hidden"); + if (this._isPathDir(path)) + return this._setError("Open failed: Is directory"); + if (!entry.perms.read) return this._setError("Open failed: Read denied"); + this.readActivity = true; + this.lastReadPath = path; + entry.accessed = Date.now(); + return entry.content; + } + + del({ STR }) { + this.lastError = ""; + const path = this._normalizePath(STR); + if (!path) return this._setError("Invalid path"); + if (path === "/") + return this._setError("Delete failed: Cannot delete root"); + if (!this.hasPermission(path, "delete")) + return this._setError("Delete failed: Denied"); + + if (path.startsWith("/.Trash/")) { + const toDelete = []; + const stack = []; + if (this._isPathDir(path)) stack.push(path); + else toDelete.push(path); + + while (stack.length > 0) { + const curr = stack.pop(); + toDelete.push(curr); + const children = this.childIndex.get(curr); + if (children) { + for (const c of children) { + if (this._isPathDir(c)) stack.push(c); + else toDelete.push(c); + } + } + } + for (const key of toDelete) { + this.fs.delete(key); + this._removeFromIndex(key); + } + } else { + this._ensureTrash(); + const name = path.endsWith("/") + ? path.split("/").slice(-2, -1)[0] + "/" + : path.split("/").pop(); + const trashPath = `/.Trash/${Date.now()}_${name}`; + + this.copy({ + STR: path, + STR2: trashPath, + }); + if (!this.lastError) { + const toDelete = []; + const stack = []; + if (this._isPathDir(path)) stack.push(path); + else toDelete.push(path); + while (stack.length > 0) { + const curr = stack.pop(); + toDelete.push(curr); + const children = this.childIndex.get(curr); + if (children) { + for (const c of children) { + if (this._isPathDir(c)) stack.push(c); + else toDelete.push(c); + } + } + } + for (const key of toDelete) { + this.fs.delete(key); + this._removeFromIndex(key); + } + } + } + this.writeActivity = true; + this.lastWritePath = path; + } + + emptyTrash() { + this.lastError = ""; + const trashPath = "/.Trash/"; + if (!this.fs.has(trashPath)) return; + + const toDelete = []; + const stack = [trashPath]; + while (stack.length > 0) { + const curr = stack.pop(); + toDelete.push(curr); + const children = this.childIndex.get(curr); + if (children) { + for (const c of children) { + if (this._isPathDir(c)) stack.push(c); + else toDelete.push(c); + } + } + } + for (const key of toDelete) { + this.fs.delete(key); + this._removeFromIndex(key); + } + this._ensureTrash(); + this.writeActivity = true; + } + + folder({ STR, STR2 }) { + this.lastError = ""; + const path = this._normalizePath(STR); + if (!path) return this._setError("Invalid path"); + let entry = this.fs.get(path); + if (!entry) { + this.start({ + STR: path, + }); + entry = this.fs.get(path); + if (!entry) return; + } + if (this._isPathDir(path)) + return this._setError("Set failed: Is directory"); + if (!entry.perms.write) return this._setError("Set failed: Write denied"); + + const deltaSize = + this._getStringSize(STR2) - this._getStringSize(entry.content || ""); + if (!this._canAccommodateChange(path, deltaSize)) return; + + entry.content = STR2; + entry.modified = Date.now(); + entry.accessed = Date.now(); + this.writeActivity = true; + this.lastWritePath = path; + } + + list({ TYPE, STR }) { + this.lastError = ""; + let path = this._normalizePath(STR); + if (!path) return "[]"; + if (!this._isPathDir(path)) path += "/"; + + const entry = this.fs.get(path); + if (!entry) return "[]"; + if (!entry.perms.see) return "[]"; + + this.readActivity = true; + this.lastReadPath = path; + entry.accessed = Date.now(); + + const childrenSet = this.childIndex.get(path); + const results = []; + if (childrenSet) { + for (const childPath of childrenSet) { + const childName = childPath.substring(path.length); + if (TYPE === "all") results.push(childName); + else if (TYPE === "files" && !this._isPathDir(childPath)) + results.push(childName); + else if (TYPE === "directories" && this._isPathDir(childPath)) + results.push(childName); + } + } + results.sort(); + return JSON.stringify(results); + } + + in({ STR }) { + this.lastError = ""; + if (!this.hasPermission("/", "delete")) + return this._setError("Import denied"); + let data; + try { + data = JSON.parse(STR); + } catch (e) { + return this._setError("JSON Error"); + } + + const tempFS = new Map(); + const tempIndex = new Map(); + + const addToTempIndex = (p) => { + const parent = this._internalDirName(p); + if (!tempIndex.has(parent)) tempIndex.set(parent, new Set()); + tempIndex.get(parent).add(p); + }; + + try { + const _version = data.version || ""; // FIX: Renamed to _version to mark as intentionally unused. + let oldData = {}; + if (data.fs) oldData = data.fs; + else if (data.sy) { + /* Compatibility placeholder */ + } // FIX: Added comment to satisfy no-empty lint rule. + + if (!oldData["/"]) return this._setError("Missing root"); + oldData["/"].perms = JSON.parse(JSON.stringify(defaultPerms)); + oldData["/"].limit = -1; + + for (const path in oldData) { + if (Object.prototype.hasOwnProperty.call(oldData, path)) { + const entry = oldData[path]; + const fixedPath = this._normalizePath(path); + tempFS.set(fixedPath, JSON.parse(JSON.stringify(entry))); + + if (fixedPath !== "/") { + addToTempIndex(fixedPath); + } + } + } + this.fs = tempFS; + this.childIndex = tempIndex; + this._ensureTrash(); + this.writeActivity = true; + this.lastWritePath = "/"; + } catch (e) { + this._setError("Import error: " + e.message); + } + } + + out() { + this.lastError = ""; + this.readActivity = true; + this.lastReadPath = "/"; + const fsObject = {}; + for (const [path, entry] of this.fs.entries()) { + fsObject[path] = JSON.parse(JSON.stringify(entry)); + } + return JSON.stringify({ + version: extensionVersion, + fs: fsObject, + }); + } + + exportFileBase64({ STR, FORMAT }) { + this.lastError = ""; + const path = this._normalizePath(STR); + if (!path) return ""; + const entry = this.fs.get(path); + if (!entry) return this._setError("Export failed: Not found"); + if (this._isPathDir(path)) return this._setError("Export failed: Is dir"); + if (!entry.perms.see || !entry.perms.read) + return this._setError("Export failed: Denied"); + + this.readActivity = true; + this.lastReadPath = path; + entry.accessed = Date.now(); + + const b64 = this._encodeUTF8Base64(String(entry.content)); + if (FORMAT === "data_url") { + return `data:${this._getMimeType(path)};base64,${b64}`; + } + return b64; + } + + importFileBase64({ FORMAT, STR, STR2 }) { + this.lastError = ""; + const path = this._normalizePath(STR2); + if (!path || this._isPathDir(path)) return this._setError("Invalid path"); + if (!STR || !STR.trim()) return this._setError("Empty input"); + + let base64String = STR.replace(/\s+/g, ""); + const match = base64String.match(/^data:.*?,(.*)$/); + if (match) base64String = match[1]; + + if ( + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + base64String + ) + ) { + return this._setError("Invalid Base64"); + } + + const decoded = this._decodeUTF8Base64(base64String); + this.folder({ + STR: path, + STR2: decoded, + }); + if (!this.lastError) this.lastWritePath = path; + } + + exists({ STR }) { + const path = this._normalizePath(STR); + if (!path) return false; + const entry = this.fs.get(path); + return !!(entry && entry.perms.see); + } + + isFile({ STR }) { + const path = this._normalizePath(STR); + if (!path) return false; + const entry = this.fs.get(path); + if (!entry || !entry.perms.see) return false; + return !this._isPathDir(path); + } + + isDir({ STR }) { + const path = this._normalizePath(STR); + if (!path) return false; + const entry = this.fs.get(path); + if (!entry || !entry.perms.see) return false; + return this._isPathDir(path); + } + + fileName({ STR }) { + const path = this._normalizePath(STR); + if (!path) return ""; + if (path === "/") return "/"; + const entry = this.fs.get(path); + if (!entry || !entry.perms.see) return ""; + + if (this._isPathDir(path)) { + const parts = path.split("/").filter((p) => p); + return parts.length ? parts[parts.length - 1] : ""; + } + return path.split("/").pop(); + } + + dirName({ STR }) { + const path = this._normalizePath(STR); + if (!path || path === "/") return ""; + const entry = this.fs.get(path); + if (!entry || !entry.perms.see) return ""; + return this._internalDirName(path); + } + + setLimit({ DIR, BYTES }) { + this.lastError = ""; + let path = this._normalizePath(DIR); + if (!path || path === "/" || !this._isPathDir(path)) + return this._setError("Invalid path"); + if (!this.hasPermission(path, "control")) return this._setError("Denied"); + const entry = this.fs.get(path); + if (!entry) return this._setError("Not found"); + entry.limit = Math.max(-1, parseFloat(BYTES) || 0); + this.writeActivity = true; + } + removeLimit({ DIR }) { + this.lastError = ""; + let path = this._normalizePath(DIR); + if (!path || path === "/" || !this._isPathDir(path)) + return this._setError("Invalid path"); + if (!this.hasPermission(path, "control")) return this._setError("Denied"); + const entry = this.fs.get(path); + if (!entry) return this._setError("Not found"); + entry.limit = -1; + this.writeActivity = true; + } + getLimit({ DIR }) { + let path = this._normalizePath(DIR); + if (!path) return -1; + if (!this._isPathDir(path)) path += "/"; + const entry = this.fs.get(path); + if (!entry || !entry.perms.see) return -1; + return entry.limit; + } + getSize({ DIR }) { + let path = this._normalizePath(DIR); + if (!path) return 0; + if (!this._isPathDir(path)) path += "/"; + const entry = this.fs.get(path); + if (!entry || !entry.perms.see) return 0; + return this._getDirectorySize(path); + } + setPerm({ ACTION, PERM, STR }) { + this.lastError = ""; + const path = this._normalizePath(STR); + if (!path || path === "/") return this._setError("Invalid"); + if (!this.hasPermission(path, "control")) return this._setError("Denied"); + const val = ACTION === "add"; + const isDir = this._isPathDir(path); + const prefix = path.endsWith("/") ? path : path + "/"; + for (const [p, e] of this.fs.entries()) { + if ((isDir && (p === path || p.startsWith(prefix))) || p === path) + e.perms[PERM] = val; + } + this.writeActivity = true; + } + listPerms({ STR }) { + const path = this._normalizePath(STR); + if (!path) return "{}"; + const e = this.fs.get(path); + if (!e || !e.perms.see) return "{}"; + return JSON.stringify(e.perms); + } + + dateCreated({ STR }) { + const p = this._normalizePath(STR); + const e = this.fs.get(p); + return e && e.perms.see ? new Date(e.created).toISOString() : ""; + } + dateModified({ STR }) { + const p = this._normalizePath(STR); + const e = this.fs.get(p); + return e && e.perms.see ? new Date(e.modified).toISOString() : ""; + } + dateAccessed({ STR }) { + const p = this._normalizePath(STR); + const e = this.fs.get(p); + return e && e.perms.see ? new Date(e.accessed).toISOString() : ""; + } + + wasRead() { + const v = this.readActivity; + this.readActivity = false; + return v; + } + wasWritten() { + const v = this.writeActivity; + this.writeActivity = false; + return v; + } + getLastReadPath() { + return this.lastReadPath; + } + getLastWritePath() { + return this.lastWritePath; + } + getLastError() { + return this.lastError; + } + toggleLogging({ STATE }) { + this.RubyFSLogEnabled = STATE === "on"; + } + getVersion() { + return extensionVersion; + } + + runIntegrityTest() { + const oldFS = this.fs; + const oldIndex = this.childIndex; + this.fs = new Map(); + this.childIndex = new Map(); + this._internalClean(); + try { + this.start({ + STR: "/a.txt", + }); + if (!this.childIndex.get("/").has("/a.txt")) + throw new Error("Index failed"); + this.del({ + STR: "/a.txt", + }); + + if (this.lastError) + throw new Error("Delete op failed: " + this.lastError); + + const trash = this.childIndex.get("/.Trash/"); + if (!trash || !trash.size) throw new Error("Trash failed"); + + this.emptyTrash(); + + const emptyTrash = this.childIndex.get("/.Trash/"); + if (emptyTrash && emptyTrash.size) throw new Error("Empty failed"); + } catch (e) { + this.fs = oldFS; + this.childIndex = oldIndex; + return "FAIL: " + e.message; + } + this.fs = oldFS; + this.childIndex = oldIndex; + return "PASS"; + } + } + + Scratch.extensions.register(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