const VFS_STORAGE_KEY = 'jBB_FileSystem';
/**
* Emulates a file system within the browser's localStorage,
* providing a BlitzBasic-like API for file and directory operations.
*/
export class File{
/** @private @type {object} In-memory representation of the virtual file system. */
#vfs;
/** @private @type {Array<object|null>} Handles for open files. Index 0 is unused. */
#openFiles = [null];
/** @private @type {Array<object|null>} Handles for open directories. Index 0 is unused. */
#openDirs = [null];
/** @private @type {string} Current working directory. */
#currentDir = '/';
constructor() {
this.#loadVFS();
}
/**
* @private
* Loads the virtual file system from localStorage or creates a new one.
*/
#loadVFS() {
const storedVFS = localStorage.getItem(VFS_STORAGE_KEY);
if (storedVFS) {
try {
this.#vfs = JSON.parse(storedVFS);
} catch (e) {
console.error("jBB FileSystem: Could not parse VFS from localStorage. Resetting.", e);
this.#createDefaultVFS();
}
} else {
this.#createDefaultVFS();
}
}
/**
* @private
* Saves the current in-memory VFS to localStorage.
*/
#saveVFS() {
try {
localStorage.setItem(VFS_STORAGE_KEY, JSON.stringify(this.#vfs));
} catch (e) {
console.error("jBB FileSystem: Could not save VFS to localStorage (storage might be full).", e);
}
}
/**
* @private
* Creates the default root directory structure.
*/
#createDefaultVFS() {
this.#vfs = {
type: 'directory',
name: '/',
children: {}
};
this.#saveVFS();
}
/**
* @private
* Finds the first available null slot in a handle array or creates a new one.
* @param {Array} handleArray The array to search for a free handle.
* @returns {number} The free handle index.
*/
#getFreeHandle(handleArray) {
for (let i = 1; i < handleArray.length; i++) {
if (handleArray[i] === null) {
return i;
}
}
return handleArray.push(null) - 1;
}
/**
* @private
* Resolves a path string to a node in the VFS.
* @param {string} rawPath The path to resolve (e.g., "saves/game.dat" or "../stuff/file").
* @param {boolean} [createParents=false] If true, creates parent directories if they don't exist.
* @returns {{node: object, parent: object, name: string, resolvedParts: Array<string>}|null} The found node, its parent, its name, and the resolved path components.
*/
#resolvePath(rawPath, createParents = false) {
const initialParts = rawPath.startsWith('/') ? [] : this.#currentDir.split('/').filter(p => p);
const relativeParts = rawPath.split('/');
for (const part of relativeParts) {
if (part === '..') {
initialParts.pop();
} else if (part !== '.' && part !== '') {
initialParts.push(part);
}
}
const resolvedParts = initialParts;
let currentNode = this.#vfs;
let parentNode = null;
for (let i = 0; i < resolvedParts.length; i++) {
const part = resolvedParts[i];
if (currentNode.type !== 'directory') {
return null; // Can't traverse through a file
}
if (!currentNode.children[part]) {
if (createParents && i < resolvedParts.length - 1) { // Create intermediate directory
currentNode.children[part] = { type: 'directory', name: part, children: {} };
} else {
return { node: null, parent: currentNode, name: part, resolvedParts: resolvedParts };
}
}
parentNode = currentNode;
currentNode = currentNode.children[part];
}
return { node: currentNode, parent: parentNode, name: resolvedParts[resolvedParts.length - 1] || '/', resolvedParts: resolvedParts };
}
/**
* Opens a file for reading/writing. Creates the file if it doesn't exist.
* @param {string} path The path to the file.
* @returns {number} A file handle, or 0 on failure.
*/
openFile(path) {
const resolved = this.#resolvePath(path);
let fileNode = resolved ? resolved.node : null;
if (!fileNode) {
const createResolved = this.#resolvePath(path, true);
if (!createResolved || !createResolved.parent) return 0;
fileNode = { type: 'file', name: createResolved.name, content: '' };
createResolved.parent.children[createResolved.name] = fileNode;
this.#saveVFS();
}
if (fileNode.type !== 'file') return 0; // Path is a directory
const handle = this.#getFreeHandle(this.#openFiles);
this.#openFiles[handle] = { node: fileNode, pos: 0 };
return handle;
}
/**
* Reads the next line from an open file.
* @param {number} handle The file handle returned by `openFile`.
* @returns {string} The content of the line, or an empty string if at the end of the file.
*/
readFile(handle) {
const file = this.#openFiles[handle];
if (!file || file.pos >= file.node.content.length) return '';
const content = file.node.content;
const newlineIndex = content.indexOf('\n', file.pos);
let line;
if (newlineIndex === -1) {
line = content.substring(file.pos);
file.pos = content.length;
} else {
line = content.substring(file.pos, newlineIndex);
file.pos = newlineIndex + 1;
}
return line.replace('\r', ''); // Handle Windows line endings
}
/**
* Appends a line of text (with a newline character) to an open file.
* @param {number} handle The file handle.
* @param {string} text The text to write.
*/
writeFile(handle, text) {
const file = this.#openFiles[handle];
if (!file) return;
const textWithNewline = text + '\n';
file.node.content += textWithNewline;
file.pos = file.node.content.length;
this.#saveVFS();
}
/**
* Closes an open file handle.
* @param {number} handle The file handle to close.
*/
closeFile(handle) {
if (this.#openFiles[handle]) {
this.#openFiles[handle] = null;
}
}
filePos(handle) {
/**
* Gets the current read/write position in an open file.
* @param {number} handle The file handle.
* @returns {number} The current position (cursor) in the file.
*/
const file = this.#openFiles[handle];
return file ? file.pos : 0;
}
/**
* Sets the read/write position for an open file.
* @param {number} handle The file handle.
* @param {number} pos The new position to seek to.
*/
seekFile(handle, pos) {
const file = this.#openFiles[handle];
if (file) {
file.pos = Math.max(0, Math.min(pos, file.node.content.length));
}
}
/**
* Checks if the end of an open file has been reached.
* @param {number} handle The file handle.
* @returns {boolean} True if the file cursor is at or beyond the end of the file.
*/
eof(handle) {
const file = this.#openFiles[handle];
return !file || file.pos >= file.node.content.length;
}
/**
* Opens a directory for reading its contents.
* @param {string} path The path to the directory.
* @returns {number} A directory handle, or 0 on failure.
*/
readDir(path) {
const resolved = this.#resolvePath(path);
if (!resolved || !resolved.node || resolved.node.type !== 'directory') return 0;
const handle = this.#getFreeHandle(this.#openDirs);
this.#openDirs[handle] = {
keys: Object.keys(resolved.node.children),
index: 0
};
return handle;
}
/**
* Reads the next entry from an open directory handle.
* @param {number} handle The directory handle.
* @returns {string} The name of the next file or directory, or an empty string if no more entries exist.
*/
nextFile(handle) {
const dir = this.#openDirs[handle];
if (dir && dir.index < dir.keys.length) {
return dir.keys[dir.index++];
}
return '';
}
closeDir(handle) {
if (this.#openDirs[handle]) {
this.#openDirs[handle] = null;
}
}
currentDir() {
return this.#currentDir;
}
changeDir(path) {
const resolved = this.#resolvePath(path);
if (resolved && resolved.node && resolved.node.type === 'directory') {
// Use the fully resolved, absolute path
this.#currentDir = resolved.resolvedParts.length > 0 ? '/' + resolved.resolvedParts.join('/') : '/';
return true;
}
return false;
}
createDir(path) {
const resolved = this.#resolvePath(path, true);
if (!resolved || !resolved.parent) return false;
if (!resolved.node) {
resolved.parent.children[resolved.name] = { type: 'directory', name: resolved.name, children: {} };
this.#saveVFS();
return true;
}
return resolved.node.type === 'directory'; // Return true if it already exists as a directory
}
/**
* Deletes an empty directory.
* @param {string} path The path of the directory to delete.
* @returns {boolean} True on success, false if the directory is not found or not empty.
*/
deleteDir(path) {
const resolved = this.#resolvePath(path);
if (resolved && resolved.node && resolved.node.type === 'directory' && resolved.parent) {
if (Object.keys(resolved.node.children).length > 0) {
return false; // Directory not empty
}
delete resolved.parent.children[resolved.name];
this.#saveVFS();
return true;
}
return false;
}
/**
* Determines the type of a path.
* @param {string} path The path to check.
* @returns {number} 0 for not found, 1 for a file, 2 for a directory.
*/
fileType(path) {
const resolved = this.#resolvePath(path);
if (!resolved || !resolved.node) return 0; // Not found
if (resolved.node.type === 'file') return 1;
if (resolved.node.type === 'directory') return 2;
return 0;
}
/**
* Gets the size of a file in bytes.
* @param {string} path The path to the file.
* @returns {number} The size of the file, or 0 if not found or it's a directory.
*/
fileSize(path) {
const resolved = this.#resolvePath(path);
if (resolved && resolved.node && resolved.node.type === 'file') {
return resolved.node.content.length;
}
return 0;
}
/**
* Copies a file from a source path to a destination path.
* @param {string} sourcePath The path of the file to copy.
* @param {string} destPath The destination path for the new file.
* @returns {boolean} True on success, false on failure.
*/
copyFile(sourcePath, destPath) {
const sourceResolved = this.#resolvePath(sourcePath);
if (!sourceResolved || !sourceResolved.node || sourceResolved.node.type !== 'file') return false;
const sourceNode = sourceResolved.node;
const destResolved = this.#resolvePath(destPath, true);
if (!destResolved || !destResolved.parent) return false;
destResolved.parent.children[destResolved.name] = {
type: 'file',
name: destResolved.name,
content: sourceNode.content,
lastModified: sourceNode.lastModified || null // Preserve the lastModified timestamp
};
this.#saveVFS();
return true;
}
/**
* Deletes a file.
* @param {string} path The path of the file to delete.
* @returns {boolean} True on success, false if the file is not found or is currently open.
*/
deleteFile(path) {
const resolved = this.#resolvePath(path);
if (resolved && resolved.node && resolved.parent && resolved.node.type === 'file') {
for (const fileHandle of this.#openFiles) {
if (fileHandle && fileHandle.node === resolved.node) {
console.error(`jBB FileSystem: Cannot delete file "${path}" because it is currently open.`);
return false;
}
}
delete resolved.parent.children[resolved.name];
this.#saveVFS();
return true;
}
return false;
}
/**
* Checks if a file exists at the given path.
* @param {string} path The path to check.
* @returns {boolean} True if a file exists, false otherwise.
*/
fileExists(path) {
return this.fileType(path) === 1;
}
/**
* Triggers a browser download for a file stored in the VFS.
* @param {string} path The path of the file within the VFS to download.
* @param {string} [downloadName] Optional. The name for the downloaded file. Defaults to the file's name in the path.
* @returns {boolean} True if the download was initiated, false if the file was not found.
*/
downloadFile(path, downloadName) {
const resolved = this.#resolvePath(path);
if (!resolved || !resolved.node || resolved.node.type !== 'file') {
console.error(`jBB FileSystem: File not found in VFS at path "${path}".`);
return false;
}
const content = resolved.node.content;
const blob = new Blob([content], { type: 'application/octet-stream' });
const finalDownloadName = downloadName || resolved.name;
const a = document.createElement('a');
a.style.display = 'none';
document.body.appendChild(a);
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = finalDownloadName;
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
return true;
}
/**
* Asynchronously loads a text file. It first checks the VFS. If not found,
* it tries to fetch it from the server and caches it in the VFS for future use.
* @param {string} path The path to the text file.
* @returns {Promise<string|null>} A promise that resolves with the file content or null if not found.
*/
async loadTextFile(path) {
const resolved = this.#resolvePath(path);
const vfsNode = resolved?.node;
if (vfsNode && vfsNode.type === 'file') {
try {
const headResponse = await fetch(path, { method: 'HEAD' });
if (headResponse.ok) {
const serverLastModified = headResponse.headers.get('Last-Modified');
if (serverLastModified && vfsNode.lastModified === serverLastModified) {
return vfsNode.content; // Serve from cache
}
}
} catch (e) {
// If HEAD request fails (e.g., CORS, network down), return the cached version for offline-like capabilities.
console.warn(`jBB FileSystem: Could not check for update on "${path}". Serving from cache.`, e);
return vfsNode.content;
}
}
try {
const getResponse = await fetch(path);
if (!getResponse.ok) {
if (vfsNode) {
console.warn(`jBB FileSystem: Failed to refetch "${path}" (e.g. 404). Serving stale version from cache.`);
return vfsNode.content;
}
return null;
}
const textContent = await getResponse.text();
const newLastModified = getResponse.headers.get('Last-Modified');
const destResolved = this.#resolvePath(path, true);
if (destResolved && destResolved.parent) {
destResolved.parent.children[destResolved.name] = {
type: 'file',
name: destResolved.name,
content: textContent,
lastModified: newLastModified || null // Store the new timestamp or null
};
this.#saveVFS();
}
return textContent;
} catch (error) {
console.error(`jBB FileSystem: Network error during fetch for "${path}"`, error);
if (vfsNode) {
console.warn(`jBB FileSystem: Serving stale version of "${path}" due to network error.`);
return vfsNode.content;
}
return null;
}
}
}