@tobshub/browser-file-system
Version:
File system for your browser
334 lines (330 loc) • 10 kB
JavaScript
import localforage, { INDEXEDDB, LOCALSTORAGE } from 'localforage';
const storage = localforage.createInstance({
name: "browser-file-storage",
description: "file storage for your browser"
});
class Store {
constructor(key, options) {
this.key = key;
this.options = options;
this.storage = storage;
this.init();
}
storage;
init() {
if (!this.options || this.options.driver === "indexeddb") {
this.storage.config({ driver: INDEXEDDB });
} else {
this.storage.config({ driver: LOCALSTORAGE });
}
}
get() {
return this.storage.getItem(this.key);
}
set(value) {
return this.storage.setItem(this.key, value);
}
}
class BrowserFS {
constructor(key, storage) {
this.key = key;
this.pathTo = [];
this.name = "root";
this.type = "root";
this.children = [];
this.storage = new Store(this.key, storage ? { driver: storage } : void 0);
}
pathTo;
name;
type;
children;
storage;
/** Initialize BrowserFS or load existing data from storage */
async init() {
const root = await this.storage.get();
if (root) {
this.children = root.children;
return;
}
this.save();
}
/** Save the current file system in the storage */
async save() {
await this.storage.set({ children: this.children, name: this.name, type: this.type });
}
/**
* Takes a path (relative or absolute) to an item in the file system and returns an array of the absolute path to the input
* @returns an array containing moves to a specific directory
*/
normalisePath(pathTo) {
const path = pathTo.split("/");
let absolutePath = [...this.pathTo];
if (path[0] === "") {
absolutePath = [];
path.shift();
}
for (let dir of path) {
switch (dir) {
case "..": {
absolutePath.pop();
}
case ".":
case "": {
break;
}
default: {
absolutePath.push(dir);
}
}
}
return absolutePath;
}
/** @returns the current active directory path */
getCurrentPath() {
return this.pathTo.join("/");
}
/**
* Takes a path to an item in the file system and returns that item if it is found
*
* Returns null if the item isn't found
* @param pathTo - a relative or absolute path to an item in the file system
* @returns the item at the end of the path or null if the item is not found
*/
getItemAtPath(pathTo) {
const path = this.normalisePath(pathTo ?? ".");
let item = this;
for (let move of path) {
if (!item || item.type === "file") {
throw new Error("item doesn't exist");
}
const nextItem = item.children.find((child) => child.name === move) ?? null;
item = nextItem;
}
return {
item: item ? item.type === "root" ? this : item.type === "dir" ? new BrowserFSDir(item.name, item.children, this) : new BrowserFSFile(item.name, item.content, this) : null,
path
};
}
/**
* Takes a path to an item in the file system and returns that item if it is found
*
* Returns null if the item isn't found
* @param pathTo - a relative or absolute path to an item in the file system
* @returns the item at the end of the path or null if the item is not found
*/
getRawItemAtPath(pathTo) {
const path = this.normalisePath(pathTo ?? ".");
let parent = null;
let item = this;
for (let move of path) {
if (!item || item.type === "file") {
throw new Error("path does not exist");
}
const nextItem = item.children.find((child) => child.name === move) ?? null;
parent = item;
item = nextItem;
}
return { item, parent, path };
}
/**
* Takes a path to an directory in the file system and sets the `this.pathTo` to the absolute path of that directory
*
* Fails if the path points to a file or does not exist
* @param path - the relative or absolute path to the directory in the file system
* */
setCurrentDir(path) {
const { item, path: pathTo } = this.getItemAtPath(path);
if (!item) {
throw new Error("directory does not exist");
}
if (item.type === "file") {
throw new Error("can't change active directory to a file");
}
this.pathTo = pathTo;
}
/**
* Adds the children to the item at the provided path
*
* Throws an error if the item at the path does not exist or is a file
*
* @param path - a relative or absolute path to a directory in the file system
* @param children - an array of children to add to the item at the specified path
*/
async addChildren(path, children) {
const { item } = this.getItemAtPath(path);
if (!item) {
throw new Error("folder does not exist");
}
if (item.type === "file") {
throw new Error("can't add children file");
}
for (let newChild of children) {
if (item.children.find((child) => child.name === newChild.name)) {
throw new Error("item already exists");
}
item.children.push(newChild);
}
await this.save();
}
/**
* Removes an item from the file system
*
* Throws an error if the item's parent does not exist
*
* @param pathTo - a relative or absolute path to an item in the file system
*/
async removeItem(pathTo) {
const { item, parent, path } = this.getRawItemAtPath(pathTo);
if (!item || !parent) {
throw new Error("item does not exist");
}
if (this.getCurrentPath().startsWith(path.join("/"))) {
throw new Error("can't remove that directory");
}
parent.children = parent.children.filter((child) => child.name !== item.name);
await this.save();
}
/**
* Renames item at the given path
*
* Throws an error if the item is a direct or indirect parent of the current path
* Throws an error if the item is the root node or the BrowserFS instance
*
* @param {String} pathTo the path the item to rename
* @param {String} newName the new name to give the item
* */
async renameItem(pathTo, newName) {
if (this.getCurrentPath().startsWith(this.normalisePath(pathTo).join("/"))) {
throw new Error("cannot rename that item");
}
const { parent, item } = this.getRawItemAtPath(pathTo);
if (!item) {
throw new Error("item does not exist");
}
if (item.type === "root") {
throw new Error("can't rename root");
}
if (parent && parent.children.find((child) => child.name === newName)) {
throw new Error("item already exists");
}
item.name = newName;
await this.save();
}
/**
* Move an item at `pathTo` to `newParentPath`
*
* Copies the item and leaves the original by default
*
* The name of the item stays the same unless `newName` is provided
* @param {String} pathTo the path to the the item
* @param {String} newParentPath the parent path of the new location
* @param {Object} options (optional) options to change the type of move, e.g. full move/copy
* */
async moveItem(pathTo, newParentPath, options) {
if (this.getCurrentPath().startsWith(this.normalisePath(pathTo).join("/"))) {
throw new Error("cannot move that item");
}
const { item, parent: oldParent } = this.getRawItemAtPath(pathTo);
if (!item || !oldParent) {
throw new Error("item not found");
}
if (item.type === "root") {
throw new Error("can't move the root directory");
}
const { item: newItem, parent: newParent, path: newPath } = this.getRawItemAtPath(newParentPath);
if (!newParent && newItem?.type !== "root") {
throw new Error("can't move item to specified path");
}
if (!newItem && newParent) {
const name = newPath.pop();
const parent = new BrowserFSDir(newParent.name, newParent.children, this);
await parent.addChildren([{ ...item, name }]);
} else if (newItem) {
if (newItem.type === "file") {
throw new Error("item already exists at that path.");
}
const newItemParent = new BrowserFSDir(newItem.name, newItem.children, this);
await newItemParent.addChildren([{ ...item }]);
} else {
throw new Error("Error: unknown");
}
if (options && options.moveType === "move") {
await this.removeItem(pathTo);
}
await this.save();
}
}
class BrowserFSDir {
constructor(name, children, root) {
this.name = name;
this.root = root;
this.type = "dir";
this.children = children;
}
type;
children;
/**
* Adds the children to the item at the provided path
*
* Throws an error if the item at the path does not exist or is a file
*
* @param path - a relative or absolute path to a directory in the file system
* @param children - an array of children to add to the item at the specified path
*/
async addChildren(children) {
for (let newChild of children) {
if (this.children.find((child) => child.name === newChild.name)) {
throw new Error("item already exists");
}
this.children.push(newChild);
}
await this.save();
}
/**
* Calls the parent save function
*
* Stops when the root save function is called
* */
async save() {
await this.root.save();
}
}
class BrowserFSFile {
constructor(name, content, root) {
this.name = name;
this.content = content;
this.root = root;
this.type = "file";
this.children = null;
}
type;
children;
/**
* Writes (or overwrites) the content on the node
*
* @param content set the content of the node
* @returns the new content
*/
async write(content) {
this.content = content;
await this.save();
return content;
}
/**
* Reads the content of the node
*
* @returns the content of the node
*/
read() {
return this.content;
}
/**
* Calls the parent save function
*
* Stops when the root save function is called
* */
async save() {
await this.root.save();
}
}
export { BrowserFS as default };