Open pelikhan opened 5 months ago
Sorry for the delay @pelikhan... I like the idea but may change the way you implemented the abstraction a bit.
i can help you with the change if you give me a hint?
@pelikhan here's the abstraction I'm using in another related project...
FileStorage.ts
/**
* Filter to apply when listing files.
*/
export type ListFilesFilter = 'files' | 'folders' | 'all';
/**
* Abstract file store interface.
*/
export interface FileStorage {
/**
* Creates a new file with the given path and content.
* @remarks
* Throws an error if the file already exists.
* @param filePath Path to file to create.
* @param content Content to write to the file.
*/
createFile(filePath: string, content: Buffer|string): Promise<void>;
/**
* Creates a folder with the given path.
* @remarks
* Any missing parent folders will also be created.
* @param folderPath Path to folder to create.
*/
createFolder(folderPath: string): Promise<void>;
/**
* Deletes the file at the given path if it exists.
* @param filePath Path to file to delete.
*/
deleteFile(filePath: string): Promise<void>;
/**
* Deletes the folder at the given path if it exists.
* @remarks
* All files and folders within the folder will also be deleted.
* @param folderPath Path to folder to delete.
*/
deleteFolder(folderPath: string): Promise<void>;
/**
* Returns the details of an existing file or folder.
* @remarks
* Throws an error if the file or folder does not exist.
* @param fileOrFolderPath File or folder path to get details for.
* @returns Details of the file or folder.
*/
getDetails(fileOrFolderPath: string): Promise<FileDetails>;
/**
* Lists all files in the given folder.
* @param folderPath Folder to list files in.
* @param filter Optional. Type of entries to return. Defaults to 'all'.
*/
listFiles(folderPath: string, filter?: ListFilesFilter): Promise<FileDetails[]>;
/**
* Returns true if a file or folder exists at the given path.
* @param fileOrFolderPath File or folder path to check.
*/
pathExists(fileOrFolderPath: string): Promise<boolean>;
/**
* Reads the file at the given path.
* @param filePath Path to file to read.
*/
readFile(filePath: string): Promise<Buffer>;
/**
* Creates or replaces the file at the given path with the given content.
* @param filePath Path to file to write.
* @param content Content to write to the file.
*/
upsertFile(filePath: string, content: Buffer|string): Promise<void>;
}
/**
* Details of a file or folder.
*/
export interface FileDetails {
/**
* Name of the file or folder.
*/
name: string;
/**
* Path to the file or folder.
*/
path: string;
/**
* True if the entry is a folder.
*/
isFolder: boolean;
}
LocalFileStorage.ts
import { FileDetails, FileStorage, ListFilesFilter } from "./FileStorage";
import * as fs from 'fs/promises';
import * as path from 'path';
/**
* A `FileStorage` implementation that uses the local file system.
*/
export class LocalFileStorage implements FileStorage {
private _rootFolder: string | undefined;
/**
* Creates a new `LocalFileStorage` instance.
* @param rootFolder Optional. Root folder to use for file operations. If not provided, paths passed to operations should be fully qualified.
*/
constructor(rootFolder?: string) {
this._rootFolder = rootFolder;
}
async createFile(filePath: string, content: Buffer|string): Promise<void> {
// Convert content to buffer if it's a string
if (typeof content == 'string') {
content = Buffer.from(content, 'utf8');
}
// Write the file
await fs.writeFile(this.getFullPath(filePath), content, { flag: 'wx' });
}
async createFolder(folderPath: string): Promise<void> {
await fs.mkdir(this.getFullPath(folderPath), { recursive: true });
}
async deleteFile(filePath: string): Promise<void> {
await fs.unlink(this.getFullPath(filePath));
}
async deleteFolder(folderPath: string): Promise<void> {
await fs.rmdir(this.getFullPath(folderPath), { recursive: true });
}
async getDetails(fileOrFolderPath: string): Promise<FileDetails> {
const stats = await fs.stat(this.getFullPath(fileOrFolderPath));
return {
name: path.basename(fileOrFolderPath),
path: fileOrFolderPath,
isFolder: stats.isDirectory(),
fileType: stats.isFile() ? FileStorageUtilities.getFileType(fileOrFolderPath) : undefined
};
}
async listFiles(folderPath: string, filter?: ListFilesFilter | undefined): Promise<FileDetails[]> {
const folder = this.getFullPath(folderPath);
const list = await fs.readdir(folder, { withFileTypes: true });
return list.map((entry) => {
return {
name: entry.name,
path: path.join(folder, entry.name),
isFolder: entry.isDirectory(),
fileType: entry.isFile() ? FileStorageUtilities.getFileType(entry.name) : undefined
};
});
}
async pathExists(fileOrFolderPath: string): Promise<boolean> {
try {
await fs.access(this.getFullPath(fileOrFolderPath));
return true;
} catch (err: unknown) {
return false;
}
}
async readFile(filePath: string): Promise<Buffer> {
return await fs.readFile(this.getFullPath(filePath));
}
async upsertFile(filePath: string, content: Buffer|string): Promise<void> {
// Convert content to buffer if it's a string
if (typeof content == 'string') {
content = Buffer.from(content, 'utf8');
}
// Write the file
await fs.writeFile(this.getFullPath(filePath), content, { flag: 'w' });
}
private getFullPath(relativePath: string): string {
if (!this._rootFolder) {
return relativePath.length > 0 ? relativePath : '.';
} else if (relativePath.length == 0) {
return this._rootFolder;
} else {
return path.join(this._rootFolder, relativePath);
}
}
}
And here's an in-memory implementation:
VirtualFileStorage.ts
import { FileDetails, FileStorage, ListFilesFilter } from "./FileStorage";
import * as path from "path";
/**
* An in-memory `FileStorage` implementation.
*/
export class VirtualFileStorage implements FileStorage {
private readonly _entries: Map<string, StoredFile> = new Map();
public createFile(filePath: string, content: Buffer|string): Promise<void> {
// Check if the file already exists
const key = path.normalize(filePath);
if (this._entries.has(key)) {
throw new Error(`File already exists: ${key}`);
}
// Convert content to buffer if it's a string
if (typeof content == 'string') {
content = Buffer.from(content, 'utf8');
}
// Create the file
this._entries.set(key, {
details: { name: path.basename(key), path: key, isFolder: false, fileType: FileStorageUtilities.getFileType(key) },
content
});
return Promise.resolve();
}
public createFolder(folderPath: string): Promise<void> {
// Check if the folder already exists
const key = path.normalize(folderPath);
if (this._entries.has(key)) {
// Throw error if entry is a file
if (!this._entries.get(key)!.details.isFolder) {
throw new Error(`Cannot create folder: ${key} is a file`);
}
} else {
// Create the folder
this._entries.set(key, {
details: { name: path.basename(key), path: key, isFolder: true, fileType: undefined }
});
}
return Promise.resolve();
}
public deleteFile(filePath: string): Promise<void> {
// Find entry
const key = path.normalize(filePath);
const entry = this._entries.get(key);
if (entry) {
// Check for a folder
if (entry.details.isFolder) {
throw new Error(`Cannot delete file: ${key} is a folder`);
}
// Delete the file
this._entries.delete(key);
}
return Promise.resolve();
}
public deleteFolder(folderPath: string): Promise<void> {
// Find entry
const key = path.normalize(folderPath);
const entry = this._entries.get(key);
if (entry) {
// Check for a file
if (!entry.details.isFolder) {
throw new Error(`Cannot delete folder: ${key} is a file`);
}
// Delete the folder
this._entries.delete(key);
}
return Promise.resolve();
}
public getDetails(fileOrFolderPath: string): Promise<FileDetails> {
// Find entry
const key = path.normalize(fileOrFolderPath);
const entry = this._entries.get(key);
if (!entry) {
throw new Error(`Path not found: ${key}`);
}
return Promise.resolve(entry.details);
}
public listFiles(folderPath: string, filter: ListFilesFilter = 'all'): Promise<FileDetails[]> {
// Find entries matching filter
let baseFolder = folderPath.length > 0 ? path.normalize(folderPath) : '';
const entries = Array.from(this._entries.values())
.map(e => e.details)
.filter(e => {
// Apply provided filter
if ((filter == 'files' && e.isFolder) || (filter == 'folders' && !e.isFolder)) {
return false;
}
// Ensure entry is an immediate child of the folder
const parts = e.path.split(path.sep);
const parentFolder = parts.length > 1 ? parts.slice(0, parts.length - 1).join(path.sep) : '';
return parentFolder == baseFolder;
});
return Promise.resolve(entries);
}
public pathExists(fileOrFolderPath: string): Promise<boolean> {
const key = path.normalize(fileOrFolderPath);
return Promise.resolve(this._entries.has(key));
}
public readFile(filePath: string): Promise<Buffer> {
// Find entry
const key = path.normalize(filePath);
const entry = this._entries.get(key);
if (!entry) {
throw new Error(`File not found: ${key}`);
} else if (entry.details.isFolder) {
throw new Error(`Cannot read file: ${key} is a folder`);
}
return Promise.resolve(entry.content ?? Buffer.from('', 'utf8'));
}
public upsertFile(filePath: string, content: Buffer|string): Promise<void> {
// Check if the file already exists as a folder
const key = path.normalize(filePath);
const entry = this._entries.get(key);
if (entry && entry.details.isFolder) {
throw new Error(`Cannot write file: ${key} is a folder`);
}
// Convert content to buffer if it's a string
if (typeof content == 'string') {
content = Buffer.from(content, 'utf8');
}
// Create or update the file
this._entries.set(key, {
details: { name: path.basename(key), path: key, isFolder: false },
content
});
return Promise.resolve();
}
}
/**
* @private
*/
interface StoredFile {
details: FileDetails;
content?: Buffer;
}
Allow swapping a different file system, like a Visual Studio Code fs.