diff --git a/docs/kx1bx1/rubyfs.md b/docs/kx1bx1/rubyfs.md new file mode 100644 index 0000000000..e2eca19e72 --- /dev/null +++ b/docs/kx1bx1/rubyfs.md @@ -0,0 +1,441 @@ +# Welcome to RubyFS + +**Version:** 1.5.0 + +**Author:** kx1bx1 (based on work by 0832) + +**License:** MIT + +> *Why did we choose this?* + +> `MIT`'s license specifically states that you can do whatever you want with the software, even if your software isn't open-source. Don't we all love providing code for the closed-source community? + +RubyFS is a fully virtual, structured file system implemented entirely in memory. It gives Scratch projects a realistic filesystem model: directories, files, permissions, metadata, size limits, a trash bin, and even a volatile RAM disk. + +Nothing touches the user’s real disk. Everything lives inside the project runtime. + +If you treat this like a tiny UNIX-ish filesystem, you’ll be right most of the time. + +--- + +## Core Concepts You Need to Understand First + +### Paths + +RubyFS uses normalized, absolute paths. + +* `/` is the root directory. +* Directories **must** end with `/`. +* Files **must not** end with `/`. +* `.` and `..` are resolved during normalization. +* Relative paths are automatically prefixed with `/`. + +Examples: + +* `test.txt` → `/test.txt` +* `/foo/../bar.txt` → `/bar.txt` +* `/data` (file) and `/data/` (directory) are distinct and mutually exclusive. + +### Stores: Main FS vs RAM FS + +There are two storage backends: + +| Store | Root | Persistence | +| -------- | ------- | -------------------------------- | +| Main FS | `/` | Lives for the project’s lifetime | +| RAM Disk | `/RAM/` | Cleared on project start | + +RAM behaves exactly like the main filesystem except: + +* It auto-clears when the green flag is clicked +* It is mounted under `/RAM/` +* Cross-FS operations (copy/rename) are handled explicitly + +### Permissions + +Every entry has a permission object: + +```json +{ + "create": true, + "delete": true, + "see": true, + "read": true, + "write": true, + "control": true +} +``` + +Permissions are inherited from the parent at creation time. + +Important nuance: + +* `see` controls visibility +* `read` controls reading file content +* `write` controls modifying file content and tags +* `create` controls creating children +* `delete` controls deletion +* `control` controls permission and size-limit changes + +If `see` is false, the file effectively does not exist to most blocks. + +--- + +## File Operations + +### `[ACTION] [PATH] [DATA / DEST]` + +This is the main dispatcher block. Internally it routes to different methods. + +#### create + +Creates a file or directory. + +* Trailing slash = directory +* Parent directories are auto-created if allowed +* File content starts as empty string +* Directory content is `null` + +Edge cases: + +* Cannot create `/` +* File/dir collisions are blocked +* Permission errors come from the parent directory + +#### delete + +Deletes a file or directory. + +* Non-trash paths are moved into `/.Trash/` +* Trash deletions are permanent +* Directories delete recursively + +Edge cases: + +* Cannot delete `/` or `/RAM/` +* Delete permission is required on the target +* Deleting a directory respects subtree permissions + +#### set content to + +Writes content to a file. + +* Auto-creates the file if it does not exist +* Fails if target is a directory +* Enforces directory size limits + +Important: This is not an append operation. It replaces content. + +#### copy to + +Copies a file or directory. + +* Recursive for directories +* Preserves permissions, tags, limits +* Cross-FS copy is supported + +Edge cases: + +* Destination must not exist +* Requires `read` on source and `create` on destination +* Size limits are checked for the entire copy payload + +#### rename to + +Renames or moves a file/directory. + +* Same-FS rename is atomic +* Cross-FS rename becomes copy + delete + +Edge cases: + +* Cannot rename `/` or `/RAM/` +* Destination collisions are blocked +* Size limits are enforced on the destination path + +--- + +## Reading & Listing + +### `read content of [PATH]` + +Reads file content. + +Fails if: + +* Path doesn’t exist +* Path is a directory +* `see` or `read` permission is missing + +Side effects: + +* Sets “was read” flag +* Updates access timestamp + +--- + +### `list [TYPE] under [DIR] as JSON` + +Lists immediate children. + +* TYPE: `all`, `files`, or `directories` +* Output is a JSON array of **names**, not full paths + +Edge cases: + +* Path is auto-forced to a directory +* Hidden entries (`see = false`) are excluded +* Results are sorted lexicographically + +--- + +### `list [TYPE] matching [PATTERN] in [DIR] as JSON` + +Same as `list`, but filtered using glob patterns. + +Supported glob syntax: + +* `*` → any string +* `?` → any single character + +Internally converted to a regex. + +Important: + +* Matching is done on the child name only +* This is non-recursive + +--- + +## Info & Checks + +### `check if [PATH] [CONDITION]` + +Conditions: + +* `exists` — entry exists and is visible +* `is file` +* `is directory` +* `was read` +* `was written` + +The last two are **edge-triggered**: + +* They return true once +* Then reset automatically + +Useful for event-style logic without hats. + +--- + +### `get [ATTRIBUTE] of [PATH]` + +Attributes include: + +* file name +* directory path +* size (bytes) +* size limit +* hash (FNV-1a) +* tree structure +* date created / modified / accessed +* last read path +* last write path +* last error +* version + +Notes: + +* Dates are ISO strings +* `tree structure` returns an ASCII tree (not JSON) +* `hash` reads file content, so permissions apply + +--- + +## Metadata (Tags) + +Tags are arbitrary key/value pairs stored per entry. + +### set tag + +Requires `write` permission. + +### get tag + +Requires `see` permission. +Returns empty string if missing. + +### delete tag + +Requires `write` permission. + +Tags are copied during copy operations and preserved in exports. + +--- + +## Permissions + +### `[add/remove] [PERM] permission for [PATH]` + +Applies recursively if PATH is a directory. + +Rules: + +* Requires `control` permission on PATH +* Cannot modify `/` +* Affects both files and directories under that path + +This is intentionally powerful. You can lock entire subtrees. + +--- + +### `list permissions for [PATH]` + +Returns a JSON object of the permission flags. + +If `see` is false, returns `{}`. + +--- + +## Size Limits + +### set size limit + +Applies to directories only. + +* Limit is in bytes (string length) +* `-1` means unlimited +* Enforced recursively for all descendants + +Writes that exceed the limit fail before modifying anything. + +### remove size limit + +Resets limit back to unlimited. + +--- + +## Import & Export + +### export file system + +Exports the entire main filesystem as JSON. + +* RAM disk is excluded +* Includes permissions, tags, timestamps + +### import file system from [JSON] + +Replaces the entire filesystem. + +Important behaviors: + +* Requires delete permission on `/` +* RAM disk is cleared +* Trash is recreated +* Root permissions are reset to defaults + +Invalid JSON or missing root aborts safely. + +--- + +### export file [PATH] as Base64 / Data URL + +Reads file content and encodes it. + +* Respects read permissions +* MIME type is inferred from extension +* Data URL includes MIME header + +--- + +### import Base64 [DATA] to file [PATH] + +Decodes Base64 and writes to a file. + +* Validates Base64 format +* Accepts raw Base64 or full Data URLs +* Auto-creates the file if needed + +--- + +## Events & Debugging + +### when file at [PATH] changes + +Hat block triggered when: + +* File content changes +* File is deleted +* File is renamed or copied into that path + +Important: + +* Exact path match only +* Trigger is synchronous to the operation + +--- + +### turn console logging on/off + +Enables verbose console logging for debugging. +Does not affect functionality. + +--- + +### run integrity test + +Runs a self-check against internal invariants. + +This is meant for extension developers, not end users. +It resets the filesystem during the test. + +--- + +## Trash System + +* Deleted entries are moved into `/.Trash/` +* Trash names are timestamp-prefixed +* Deleting from trash is permanent +* Trash can be emptied via “clear trash” + +This mirrors desktop OS behavior closely. + +--- + +## RAM Disk Details + +* Path prefix: `/RAM/` +* Cleared on PROJECT_START +* Supports permissions, tags, size limits +* Cannot be deleted or renamed + +RAM is ideal for caches, temp files, and runtime data. + +--- + +## Error Handling Philosophy + +RubyFS never throws. +It reports errors via: + +* `last error` +* Silent failures in reporters +* Permission-based invisibility + +This matches Scratch’s “fail soft” philosophy and keeps projects from crashing. + +--- + +## Final Notes + +RubyFS is intentionally strict about: + +* Path correctness +* Permission boundaries +* Directory vs file semantics + +If you treat it like a toy filesystem, it will surprise you. +If you treat it like a real one, it will behave exactly how you expect. + +This is one of those extensions where the power comes from discipline, not convenience. 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..ce151c2dbe --- /dev/null +++ b/extensions/kx1bx1/rubyfs.js @@ -0,0 +1,1689 @@ +// Name: RubyFS +// ID: rubyFS +// Description: A structured, in-memory file system for Scratch projects, now with RAM, trash, and tags. +// By: kx1bx1 +// Original: 0832 +// License: MIT + +// Version: 1.5.0 +// - Fixed linting errors (translations, case scope, unused vars) +// Big update, huh? + +(function (Scratch) { + "use strict"; + + const defaultPerms = { + create: true, + delete: true, + see: true, + read: true, + write: true, + control: true, + }; + + const extensionVersion = "1.5.0"; + + class RubyFS { + constructor() { + // Main Persistent Storage + this.fs = new Map(); + this.childIndex = new Map(); + + // Volatile RamDisk Storage + this.ramfs = new Map(); + this.ramIndex = new Map(); + + this.RubyFSLogEnabled = false; + this.lastError = ""; + this.readActivity = false; + this.writeActivity = false; + this.lastReadPath = ""; + this.lastWritePath = ""; + + // Hat Block Flag (Transient) + this._eventTriggerPath = null; + + // VM Hook + this.runtime = Scratch.vm ? Scratch.vm.runtime : null; + + this._log("Initializing RubyFS v1.5.0..."); + this._internalClean(); + + if (this.runtime) { + this.runtime.on("PROJECT_START", () => { + this._log("Project start: Clearing RamDisk..."); + this.clearRamdisk(); + }); + } + } + + getInfo() { + return { + id: "rubyFS", + name: Scratch.translate("RubyFS"), + docsURI: "https://extensions.turbowarp.org/kx1bx1/rubyfs", + color1: "#d52246", + color2: "#a61734", + color3: "#7f1026", + blocks: [ + // --- Main Operations --- + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("File Operations"), + }, + { + opcode: "fsManage", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("[ACTION] [STR] [STR2]"), + arguments: { + ACTION: { + type: Scratch.ArgumentType.STRING, + menu: "MANAGE_MENU", + }, + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/file.txt", + }, + STR2: { + type: Scratch.ArgumentType.STRING, + defaultValue: "data / destination", + }, + }, + }, + { + opcode: "open", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("read content of [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/file.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/", + }, + }, + }, + { + opcode: "listGlob", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate( + "list [TYPE] matching [PATTERN] in [DIR] as JSON" + ), + arguments: { + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: "LIST_TYPE_MENU", + defaultValue: "all", + }, + PATTERN: { + type: Scratch.ArgumentType.STRING, + defaultValue: "*.txt", + }, + DIR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/", + }, + }, + }, + { + opcode: "fsClear", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("clear [TARGET]"), + arguments: { + TARGET: { type: Scratch.ArgumentType.STRING, menu: "CLEAR_MENU" }, + }, + }, + + // --- Information & Checks --- + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Info & Checks"), + }, + { + opcode: "fsCheck", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("check if [STR] [CONDITION]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/file.txt", + }, + CONDITION: { + type: Scratch.ArgumentType.STRING, + menu: "CHECK_MENU", + }, + }, + }, + { + opcode: "fsGet", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("get [ATTRIBUTE] of [STR]"), + arguments: { + ATTRIBUTE: { + type: Scratch.ArgumentType.STRING, + menu: "GET_MENU", + }, + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/file.txt", + }, + }, + }, + + // --- Metadata (Tags) --- + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Metadata (Tags)"), + }, + { + opcode: "setTag", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set tag [KEY] to [VALUE] for [PATH]"), + arguments: { + KEY: { + type: Scratch.ArgumentType.STRING, + defaultValue: "author", + }, + VALUE: { type: Scratch.ArgumentType.STRING, defaultValue: "me" }, + PATH: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/file.txt", + }, + }, + }, + { + opcode: "getTag", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("get tag [KEY] of [PATH]"), + arguments: { + KEY: { + type: Scratch.ArgumentType.STRING, + defaultValue: "author", + }, + PATH: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/file.txt", + }, + }, + }, + { + opcode: "deleteTag", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("delete tag [KEY] of [PATH]"), + arguments: { + KEY: { + type: Scratch.ArgumentType.STRING, + defaultValue: "author", + }, + PATH: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/file.txt", + }, + }, + }, + + // --- Permissions --- + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Permissions"), + }, + { + 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: "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/", + }, + }, + }, + + // --- Import/Export --- + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Import & Export"), + }, + { + opcode: "in", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("import file system from [STR]"), + arguments: { + STR: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"version":"1.5.0","fs":{}}', + }, + }, + }, + { + opcode: "out", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("export file system"), + }, + { + 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: "" }, + STR2: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/imported.txt", + }, + }, + }, + + // --- Events & Debugging --- + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Events & Debug"), + }, + { + opcode: "whenFileChanged", + blockType: Scratch.BlockType.HAT, + func: "whenFileChanged", + text: Scratch.translate("when file at [PATH] changes"), + arguments: { + PATH: { + type: Scratch.ArgumentType.STRING, + defaultValue: "/RubyFS/example.txt", + }, + }, + }, + { + 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: "runIntegrityTest", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("run integrity test"), + }, + ], + menus: { + MANAGE_MENU: { + acceptReporters: true, + items: [ + { text: "create", value: "create" }, + { text: "delete", value: "delete" }, + { text: "set content to", value: "set" }, + { text: "copy to", value: "copy" }, + { text: "rename to", value: "rename" }, + ], + }, + CLEAR_MENU: { + acceptReporters: true, + items: [ + { text: "filesystem", value: "all" }, + { text: "trash", value: "trash" }, + { text: "ramdisk", value: "ram" }, + ], + }, + CHECK_MENU: { + acceptReporters: true, + items: [ + { text: "exists", value: "exists" }, + { text: "is file", value: "file" }, + { text: "is directory", value: "directory" }, + { text: "was read", value: "read" }, + { text: "was written", value: "written" }, + ], + }, + GET_MENU: { + acceptReporters: true, + items: [ + { text: "file name", value: "name" }, + { text: "directory path", value: "dir" }, + { text: "size (bytes)", value: "size" }, + { text: "size limit", value: "limit" }, + { text: "hash (checksum)", value: "hash" }, + { text: "tree structure", value: "tree" }, + { text: "date created", value: "created" }, + { text: "date modified", value: "modified" }, + { text: "date accessed", value: "accessed" }, + { text: "last read path", value: "lastRead" }, + { text: "last write path", value: "lastWrite" }, + { text: "last error", value: "error" }, + { text: "version", value: "version" }, + ], + }, + LIST_TYPE_MENU: { + acceptReporters: true, + items: ["all", "files", "directories"], + }, + PERM_ACTION_MENU: { acceptReporters: true, items: ["add", "remove"] }, + PERM_TYPE_MENU: { + acceptReporters: true, + items: ["create", "delete", "see", "read", "write", "control"], + }, + LOG_STATE_MENU: { acceptReporters: true, items: ["on", "off"] }, + BASE64_FORMAT_MENU: { + acceptReporters: true, + items: [ + { text: "Base64 String", value: "base64" }, + { text: "Data URL", value: "data_url" }, + ], + }, + }, + }; + } + + // --- CONSOLIDATED DISPATCHERS --- + + // Default value for STR2 fixes validation error + fsManage({ ACTION, STR, STR2 = "" }) { + switch (ACTION) { + case "create": + this.start({ STR }); + break; + case "delete": + this.del({ STR }); + break; + case "set": + this.setContent({ STR, STR2 }); + break; + case "copy": + this.copy({ STR, STR2 }); + break; + case "rename": + this.sync({ STR, STR2 }); + break; + default: + this._setError(`Unknown action: ${ACTION}`); + } + } + + fsClear({ TARGET }) { + if (TARGET === "trash") this.emptyTrash(); + else if (TARGET === "ram") this.clearRamdisk(); + else this.clean(); + } + + fsCheck({ STR, CONDITION }) { + const path = this._normalizePath(STR); + switch (CONDITION) { + case "exists": + return this._exists(path); + case "file": + return this._isFile(path); + case "directory": + return this._isDir(path); + case "read": { + const r = this.readActivity; + this.readActivity = false; + return r; + } + case "written": { + const w = this.writeActivity; + this.writeActivity = false; + return w; + } + default: + return false; + } + } + + fsGet({ ATTRIBUTE, STR }) { + if (ATTRIBUTE === "lastRead") return this.lastReadPath; + if (ATTRIBUTE === "lastWrite") return this.lastWritePath; + if (ATTRIBUTE === "error") return this.lastError; + if (ATTRIBUTE === "version") return extensionVersion; + + const path = this._normalizePath(STR); + if (!path) return ""; + + switch (ATTRIBUTE) { + case "name": + return this._fileName(path); + case "dir": + return this._dirName(path); + case "size": + return this.getSize({ DIR: path }); + case "limit": + return this.getLimit({ DIR: path }); + case "hash": + return this.getHash({ PATH: path }); + case "tree": + return this.getTree({ DIR: path }); + case "created": + return this._getTimestamp(path, "created"); + case "modified": + return this._getTimestamp(path, "modified"); + case "accessed": + return this._getTimestamp(path, "accessed"); + default: + return ""; + } + } + + // --- INTERNAL IMPLEMENTATIONS --- + + _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; + } + + _triggerChange(path) { + if (this.runtime) { + this._eventTriggerPath = this._normalizePath(path); + this.runtime.startHats("rubyFS_whenFileChanged"); + this._eventTriggerPath = null; + } + } + + whenFileChanged(args) { + if (!this._eventTriggerPath) return false; + if (!args.PATH) return false; + const targetPath = this._normalizePath(args.PATH); + return targetPath === this._eventTriggerPath; + } + + _getStore(path) { + if (path.startsWith("/RAM/")) + return { fs: this.ramfs, index: this.ramIndex, isRam: true }; + return { fs: this.fs, index: this.childIndex, isRam: false }; + } + + _addToIndex(path) { + const parent = this._internalDirName(path); + // const store = this._getStore(path); // Unused + const parentStore = this._getStore(parent); + + // Virtual entry for /RAM/ in Main Root index + if (parent === "/" && path === "/RAM/") { + if (!this.childIndex.has("/")) this.childIndex.set("/", new Set()); + this.childIndex.get("/").add("/RAM/"); + return; + } + if (!parentStore.index.has(parent)) + parentStore.index.set(parent, new Set()); + parentStore.index.get(parent).add(path); + } + + _removeFromIndex(path) { + const parent = this._internalDirName(path); + const parentStore = this._getStore(parent); + if (parent === "/" && path === "/RAM/") return; + if (parentStore.index.has(parent)) + parentStore.index.get(parent).delete(path); + const store = this._getStore(path); + if (store.index.has(path)) store.index.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, + tags: {}, + 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 store = this._getStore(dirPath); + const stack = [dirPath]; + while (stack.length > 0) { + const currentPath = stack.pop(); + const children = store.index.get(currentPath); + if (children) { + for (const child of children) { + const entry = store.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 store = this._getStore(currentDir); + const entry = store.fs.get(currentDir); + // Cross-FS: Stop at /RAM/ root + if (currentDir === "/RAM/") break; + 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) { + const store = this._getStore(path); + if (store.fs.has(path)) return false; + const parentStore = this._getStore(parentDir); + + if (path === "/RAM/") return false; // System root + + 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 = parentStore.fs.get(parentDir); + if (parentEntry) permsToInherit = parentEntry.perms; + else if (parentDir === "/") permsToInherit = this.fs.get("/").perms; + else permsToInherit = defaultPerms; + + const now = Date.now(); + store.fs.set(path, { + content: content, + perms: JSON.parse(JSON.stringify(permsToInherit)), + limit: -1, + tags: {}, + created: now, + modified: now, + accessed: now, + }); + this._addToIndex(path); + this.writeActivity = true; + this.lastWritePath = path; + this._triggerChange(path); + return true; + } + + hasPermission(path, action) { + const normPath = this._normalizePath(path); + if (!normPath) return false; + const store = this._getStore(normPath); + const entry = store.fs.get(normPath); + if (entry) return entry.perms[action]; + if (action === "create") { + const parentDir = this._internalDirName(normPath); + if (parentDir === "/") { + const root = this.fs.get("/"); + return root ? root.perms.create : defaultPerms.create; + } + if (parentDir === "/RAM/") { + const ramRoot = this.ramfs.get("/RAM/"); + return ramRoot ? ramRoot.perms.create : defaultPerms.create; + } + + const parentStore = this._getStore(parentDir); + const parentEntry = parentStore.fs.get(parentDir); + if (!parentEntry) return false; + return parentEntry.perms.create; + } + return false; + } + + _internalClean() { + const now = Date.now(); + this.fs.clear(); + this.childIndex.clear(); + this.fs.set("/", { + content: null, + perms: JSON.parse(JSON.stringify(defaultPerms)), + limit: -1, + tags: {}, + created: now, + modified: now, + accessed: now, + }); + this.clearRamdisk(); + this.writeActivity = true; + this.lastWritePath = "/"; + } + + clearRamdisk() { + const now = Date.now(); + this.ramfs.clear(); + this.ramIndex.clear(); + this.ramfs.set("/RAM/", { + content: null, + perms: JSON.parse(JSON.stringify(defaultPerms)), + limit: -1, + tags: {}, + created: now, + modified: now, + accessed: now, + }); + if (!this.childIndex.has("/")) this.childIndex.set("/", new Set()); + this.childIndex.get("/").add("/RAM/"); + this._ensureTrash(); + this.writeActivity = true; + } + + clean() { + this.lastError = ""; + if (!this.hasPermission("/", "delete")) + return this._setError("Clean failed: No 'delete' permission on /"); + this._internalClean(); + } + + // --- HELPER ACCESSORS FOR DISPATCHERS --- + + _exists(path) { + if (!path) return false; + const store = this._getStore(path); + const entry = store.fs.get(path); + return !!(entry && entry.perms.see); + } + + _isFile(path) { + if (!path) return false; + const store = this._getStore(path); + const entry = store.fs.get(path); + if (!entry || !entry.perms.see) return false; + return !this._isPathDir(path); + } + + _isDir(path) { + if (!path) return false; + const store = this._getStore(path); + const entry = store.fs.get(path); + if (!entry || !entry.perms.see) return false; + return this._isPathDir(path); + } + + _fileName(path) { + if (!path || path === "/") return "/"; + const store = this._getStore(path); + const entry = store.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(path) { + if (!path || path === "/") return ""; + const store = this._getStore(path); + const entry = store.fs.get(path); + if (!entry || !entry.perms.see) return ""; + return this._internalDirName(path); + } + + // --- OPERATIONAL METHODS --- + + 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"); + + const store = this._getStore(path); + if (store.fs.has(path)) + return this._setError("Create failed: Path exists"); + + if (this._isPathDir(path)) { + if (store.fs.has(path.slice(0, -1))) + return this._setError("Create failed: File collision"); + } else { + if (store.fs.has(path + "/")) + return this._setError("Create failed: Directory collision"); + } + + const parentDir = this._internalDirName(path); + const parentStore = this._getStore(parentDir); + + if (parentDir !== "/" && parentDir !== "/RAM/") { + const pEntry = parentStore.fs.get(parentDir); + if (pEntry && !pEntry.perms.see) + return this._setError("Create failed: Parent hidden"); + } + + if ( + parentDir !== "/" && + parentDir !== "/RAM/" && + !parentStore.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 store = this._getStore(path); + const entry = store.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 === "/" || path === "/RAM/") + return this._setError("Delete failed: Cannot delete root/mount"); + if (!this.hasPermission(path, "delete")) + return this._setError("Delete failed: Denied"); + + const store = this._getStore(path); + + if (path.startsWith("/.Trash/")) { + // Permanent + 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 = store.index.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) { + store.fs.delete(key); + this._removeFromIndex(key); + } + } else { + // Move to Trash + 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 }); // Cross-FS aware copy + + 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 = store.index.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) { + store.fs.delete(key); + this._removeFromIndex(key); + } + } + } + this.writeActivity = true; + this.lastWritePath = path; + this._triggerChange(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; + } + + // Renamed from 'folder' to 'setContent' + setContent({ STR, STR2 }) { + this.lastError = ""; + const path = this._normalizePath(STR); + if (!path) return this._setError("Invalid path"); + + const store = this._getStore(path); + let entry = store.fs.get(path); + if (!entry) { + this.start({ STR: path }); + entry = store.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; + this._triggerChange(path); + } + + // RESTORED 'list' method + list({ TYPE, STR }) { + this.lastError = ""; + let path = this._normalizePath(STR); + if (!path) return "[]"; + if (!this._isPathDir(path)) path += "/"; + + const store = this._getStore(path); + const entry = store.fs.get(path); + if (!entry) { + this._setError("List failed: Directory not found"); + return "[]"; + } + if (!entry.perms.see) { + this._setError("List failed: Directory hidden"); + return "[]"; + } + + this.readActivity = true; + this.lastReadPath = path; + entry.accessed = Date.now(); + + const childrenSet = store.index.get(path); + const results = []; + if (childrenSet) { + for (const childPath of childrenSet) { + const childEntry = store.fs.get(childPath); + if (!childEntry || !childEntry.perms.see) continue; + + 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); + } + + 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 === "/" || path1 === "/RAM/") + return this._setError("Rename failed: Root/Mount cannot be renamed"); + + const store1 = this._getStore(path1); + const store2 = this._getStore(path2); + + // Cross-FS? Use Copy+Delete + if (store1.isRam !== store2.isRam) { + this.copy({ STR, STR2 }); + if (this.lastError) return; + this.del({ STR }); + return; + } + + if (!this.hasPermission(path1, "delete")) + return this._setError("Rename failed: No 'delete' permission"); + if (store2.fs.has(path2)) + return this._setError("Rename failed: Destination exists"); + + if (this._isPathDir(path2)) { + if (store2.fs.has(path2.slice(0, -1))) + return this._setError("Rename failed: File collision"); + } else { + if (store2.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 = store1.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 = []; + const stack = []; + + if (isDir) { + stack.push(path1); + while (stack.length > 0) { + const curr = stack.pop(); + toRename.push(curr); + const children = store1.index.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 = store1.fs.get(oldKey); + if (!entryVal) continue; + const remainder = oldKey.substring(path1Length); + const newKey = path2 + remainder; + if (oldKey === path1) { + entryVal.modified = now; + entryVal.accessed = now; + } + store1.fs.set(newKey, entryVal); + store1.fs.delete(oldKey); + this._removeFromIndex(oldKey); + this._addToIndex(newKey); + } + this.writeActivity = true; + this.lastWritePath = path2; + this._triggerChange(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 store1 = this._getStore(path1); + const store2 = this._getStore(path2); + + const entry = store1.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 (store2.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; + const stack = []; + + if (this._isPathDir(path1)) { + stack.push(path1); + while (stack.length > 0) { + const curr = stack.pop(); + const val = store1.fs.get(curr); + toCopy.push({ key: curr, value: val }); + const children = store1.index.get(curr); + if (children) { + for (const c of children) { + if (this._isPathDir(c)) stack.push(c); + else { + const fVal = store1.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; + store2.fs.set(newPath, { + content: item.value.content === null ? null : "" + item.value.content, + perms: JSON.parse(JSON.stringify(item.value.perms)), + limit: item.value.limit, + tags: JSON.parse(JSON.stringify(item.value.tags || {})), + created: now, + modified: now, + accessed: now, + }); + this._addToIndex(newPath); + } + this.writeActivity = true; + this.lastWritePath = path2; + this._triggerChange(path2); + } + + _getTimestamp(path, type) { + const store = this._getStore(path); + const entry = store.fs.get(path); + if (!entry || !entry.perms.see) return ""; + this.readActivity = true; + this.lastReadPath = path; + entry.accessed = Date.now(); + return new Date(entry[type]).toISOString(); + } + + toggleLogging({ STATE }) { + this.RubyFSLogEnabled = STATE === "on"; + } + getVersion() { + return extensionVersion; + } + + 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 store = this._getStore(path); + const entry = store.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 store = this._getStore(path); + const entry = store.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 store = this._getStore(path); + const entry = store.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 store = this._getStore(path); + const entry = store.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 + "/"; + const store = this._getStore(path); + for (const [p, e] of store.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 store = this._getStore(path); + const e = store.fs.get(path); + if (!e || !e.perms.see) return "{}"; + return JSON.stringify(e.perms); + } + + // --- Base64 & Import/Export --- + _encodeUTF8Base64(str) { + try { + return btoa(str); + } catch (e) { + try { + return btoa( + encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (m, p1) => + String.fromCharCode(parseInt(p1, 16)) + ) + ); + } catch (e2) { + this._setError(`Base64 Error: ${e2.message}`); + return ""; + } + } + } + _decodeUTF8Base64(base64) { + try { + return decodeURIComponent( + atob(base64) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join("") + ); + } catch (e) { + return atob(base64); + } + } + _getMimeType(path) { + const ext = path.split(".").pop().toLowerCase(); + const mimes = { + txt: "text/plain", + json: "application/json", + svg: "image/svg+xml", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + zip: "application/zip", + sprite3: "application/x-zip-compressed", + sb3: "application/x-zip-compressed", + wav: "audio/wav", + mp3: "audio/mpeg", + }; + return mimes[ext] || "application/octet-stream"; + } + + 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 }); + } + + 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); + }; + + // Virtual /RAM/ in main index + if (!tempIndex.has("/")) tempIndex.set("/", new Set()); + tempIndex.get("/").add("/RAM/"); + + try { + // const version = data.version || ""; // REMOVED unused var + let oldData = {}; + if (data.fs) oldData = data.fs; + else if (data.sy) { + /* Migration skipped */ + } + + 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)) { + if (path.startsWith("/RAM/")) continue; // Skip RAM in imports + const entry = oldData[path]; + if (!entry.tags) entry.tags = {}; // Ensure tags exist + const fixedPath = this._normalizePath(path); + tempFS.set(fixedPath, JSON.parse(JSON.stringify(entry))); + // FIX: Do not add root to the index to avoid infinite recursion + if (fixedPath !== "/") { + addToTempIndex(fixedPath); + } + } + } + this.fs = tempFS; + this.childIndex = tempIndex; + this._ensureTrash(); + this.clearRamdisk(); + this.writeActivity = true; + this.lastWritePath = "/"; + } catch (e) { + this._setError("Import error: " + e.message); + } + } + + exportFileBase64({ STR, FORMAT }) { + this.lastError = ""; + const path = this._normalizePath(STR); + if (!path) return ""; + + const store = this._getStore(path); + const entry = store.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, "").match(/^data:.*?,(.*)$/)?.[1] || + STR.replace(/\s+/g, ""); + 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.setContent({ STR: path, STR2: decoded }); // Updated call + if (!this.lastError) this.lastWritePath = path; + } + + // --- Tags --- + setTag({ KEY, VALUE, PATH }) { + this.lastError = ""; + const path = this._normalizePath(PATH); + if (!path) return this._setError("Invalid path"); + const store = this._getStore(path); + const entry = store.fs.get(path); + if (!entry) return this._setError("Path not found"); + if (!entry.perms.write) return this._setError("Write denied"); + entry.tags[KEY] = VALUE; + entry.modified = Date.now(); + this.writeActivity = true; + this.lastWritePath = path; + } + + getTag({ KEY, PATH }) { + this.lastError = ""; + const path = this._normalizePath(PATH); + if (!path) return ""; + const store = this._getStore(path); + const entry = store.fs.get(path); + if (!entry || !entry.perms.see) return ""; + return entry.tags[KEY] || ""; + } + + deleteTag({ KEY, PATH }) { + this.lastError = ""; + const path = this._normalizePath(PATH); + if (!path) return this._setError("Invalid path"); + const store = this._getStore(path); + const entry = store.fs.get(path); + if (!entry) return this._setError("Path not found"); + if (!entry.perms.write) return this._setError("Write denied"); + delete entry.tags[KEY]; + entry.modified = Date.now(); + this.writeActivity = true; + this.lastWritePath = path; + } + + // --- New Features (Glob, Hash, Tree) --- + + listGlob({ TYPE, PATTERN, DIR }) { + this.lastError = ""; + let path = this._normalizePath(DIR); + if (!path) return "[]"; + if (!this._isPathDir(path)) path += "/"; + const store = this._getStore(path); + const entry = store.fs.get(path); + if (!entry || !entry.perms.see) return "[]"; + this.readActivity = true; + this.lastReadPath = path; + + const childrenSet = store.index.get(path); + const results = []; + // Regex conversion + const regexBody = PATTERN.split("") + .map((c) => { + if (c === "*") return ".*"; + if (c === "?") return "."; + if (/[.+^${}()|[\]\\]/.test(c)) return "\\" + c; + return c; + }) + .join(""); + const regex = new RegExp(`^${regexBody}$`); + + if (childrenSet) { + for (const childPath of childrenSet) { + const childEntry = store.fs.get(childPath); + if (!childEntry || !childEntry.perms.see) continue; + const childName = childPath.substring(path.length); + if (regex.test(childName)) { + 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); + } + + getHash({ PATH }) { + const content = this.open({ STR: PATH }); + if (typeof content !== "string" || content === "") return "0"; + let hash = 0x811c9dc5; + for (let i = 0; i < content.length; i++) { + hash ^= content.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16); + } + + getTree({ DIR }) { + let path = this._normalizePath(DIR); + if (!this._isPathDir(path)) path += "/"; + const store = this._getStore(path); + const rootEntry = store.fs.get(path); + if (!rootEntry || !rootEntry.perms.see) return "Path not found"; + + let output = path + "\n"; + const treeStack = []; + const rootChildren = Array.from(store.index.get(path) || []) + .filter((p) => { + const e = store.fs.get(p); + return e && e.perms.see; + }) + .sort(); + + for (let i = rootChildren.length - 1; i >= 0; i--) { + treeStack.push({ + path: rootChildren[i], + prefix: "", + isLast: i === rootChildren.length - 1, + }); + } + + while (treeStack.length > 0) { + const { path: currentPath, prefix, isLast } = treeStack.pop(); + const name = currentPath + .substring(this._internalDirName(currentPath).length) + .replace("/", ""); + output += prefix + (isLast ? "└── " : "├── ") + name + "\n"; + if (this._isPathDir(currentPath)) { + const children = Array.from(store.index.get(currentPath) || []) + .filter((p) => { + const e = store.fs.get(p); + return e && e.perms.see; + }) + .sort(); + const newPrefix = prefix + (isLast ? " " : "│ "); + for (let i = children.length - 1; i >= 0; i--) { + treeStack.push({ + path: children[i], + prefix: newPrefix, + isLast: i === children.length - 1, + }); + } + } + } + return output; + } + + runIntegrityTest() { + const oldFS = this.fs; + const oldIndex = this.childIndex; + const oldRamFS = this.ramfs; + const oldRamIndex = this.ramIndex; + this.fs = new Map(); + this.childIndex = new Map(); + this.ramfs = new Map(); + this.ramIndex = new Map(); + this._internalClean(); + + try { + this.fsManage({ ACTION: "create", STR: "/a.txt", STR2: "" }); + if (!this.fsCheck({ STR: "/a.txt", CONDITION: "exists" })) + throw new Error("Create failed"); + this.fsManage({ ACTION: "delete", STR: "/a.txt", STR2: "" }); + if (this.fsCheck({ STR: "/a.txt", CONDITION: "exists" })) + throw new Error("Delete failed"); + + // Check Last Error before assuming undefined trash + if (this.lastError) + throw new Error("Manage op failed: " + this.lastError); + + const trash = this.childIndex.get("/.Trash/"); + if (!trash || !trash.size) throw new Error("Trash failed"); + this.fsClear({ TARGET: "trash" }); + const empty = this.childIndex.get("/.Trash/"); + if (empty && empty.size) throw new Error("Empty failed"); + + // New Feature Tests + this.fsManage({ ACTION: "create", STR: "/RAM/temp.txt", STR2: "" }); + if (!this.ramfs.has("/RAM/temp.txt")) throw new Error("RamDisk failed"); + + this.setTag({ KEY: "foo", VALUE: "bar", PATH: "/RAM/temp.txt" }); + if (this.getTag({ KEY: "foo", PATH: "/RAM/temp.txt" }) !== "bar") + throw new Error("Tag failed"); + + // List check + const listResult = JSON.parse(this.list({ TYPE: "all", STR: "/RAM/" })); + if (!listResult.includes("temp.txt")) throw new Error("List failed"); + } catch (e) { + this.fs = oldFS; + this.childIndex = oldIndex; + this.ramfs = oldRamFS; + this.ramIndex = oldRamIndex; + return "FAIL: " + e.message; + } + this.fs = oldFS; + this.childIndex = oldIndex; + this.ramfs = oldRamFS; + this.ramIndex = oldRamIndex; + 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..9975e0886d --- /dev/null +++ b/images/kx1bx1/rubyfs.svg @@ -0,0 +1 @@ + \ No newline at end of file