@timothy-spaceman/multitrack-vcs
Version:
Version Control System for musicians
385 lines (321 loc) • 13.6 kB
text/typescript
import {IFile, IStorageProvider} from "../types.js"
import {createHash} from "crypto";
import micromatch from 'micromatch';
import path from "node:path";
export class VirtualFile implements IFile {
path: string
name: string
extension: string = ""
fullPath: string // path + name + extension
data: Buffer
constructor(fullPath: string, data: Buffer) {
this.fullPath = fullPath
const parts = fullPath.split(VirtualStorageProvider.separator)
const fileName = parts.pop()
if (!fileName) {
throw new Error("Unable to parse file name")
}
this.name = fileName
if (fileName.split(".").length > 1) {
this.name = fileName.split(".").slice(0, -1).join(".")
this.extension = fileName.split(".").slice(-1).join("")
}
this.path = parts.join(VirtualStorageProvider.separator)
this.data = data
}
async readData(): Promise<Buffer> {
try {
return this.data
} catch (err: any) {
throw new Error(`Failed to read file content: ${err.message}`)
}
}
async writeData(data: Buffer): Promise<void> {
try {
this.data = data
} catch (err: any) {
throw new Error(`Failed to write file content: ${err.message}`)
}
}
async getDataHash(algo: string = "sha256"): Promise<string> {
const hash = createHash(algo)
hash.update(this.data)
return hash.digest("hex")
}
}
export type VirtualDirectoryMockCallbackArgs =
["has", boolean, string] |
["get", VirtualMockedEntry | undefined, string] |
["set", VirtualMockedEntry, string, VirtualMockedEntry] |
["delete", boolean, string] |
["clear", void] |
["keys", IterableIterator<string>] |
["values", IterableIterator<VirtualMockedEntry>] |
["entries", IterableIterator<[string, VirtualMockedEntry]>] |
["forEach", void, (value: VirtualMockedEntry, path: string, map: VirtualMockedDirectory) => void, any]
export class VirtualDirectoryMock {
content: VirtualDirectory = new Map<string, VirtualMockedEntry>()
constructor(
public callback: (...args: VirtualDirectoryMockCallbackArgs) => any
) {
this.initializeProxies()
}
initializeProxies() {
const methodsToMock = ["has", "get", "set", "delete", "clear", "keys", "values", "entries", "forEach"] as const
const chainableMethods = ["set"]
for (const methodName of methodsToMock) {
const originalMethod = this.content[methodName as keyof VirtualDirectory] as any;
(this as any)[methodName] = (...args: any) => {
const result = originalMethod.apply(this.content, args);
this.callback(...[methodName, result, ...args] as VirtualDirectoryMockCallbackArgs);
if (chainableMethods.includes(methodName)) {
return this;
}
return result;
};
}
}
get size() {
return this.content.size
}
has!: (path: string) => boolean;
get!: (path: string) => VirtualMockedEntry | undefined;
set!: (path: string, value: VirtualMockedEntry) => this;
delete!: (path: string) => boolean;
clear!: () => void;
keys!: () => IterableIterator<string>;
values!: () => IterableIterator<VirtualMockedEntry>;
entries!: () => IterableIterator<[string, VirtualMockedEntry]>;
forEach!: (callback: (value: VirtualMockedEntry, path: string, map: VirtualDirectoryMock) => void, thisArg?: any) => void;
}
export class VirtualDirectoryOverrideMock extends VirtualDirectoryMock {
initializeProxies() {
const methodsToMock = ["has", "get", "set", "delete", "clear", "keys", "values", "entries", "forEach"] as const
const chainableMethods = ["set"]
for (const methodName of methodsToMock) {
const originalMethod = this.content[methodName as keyof VirtualDirectory] as any;
(this as any)[methodName] = (...args: any) => {
const originalResult = originalMethod.apply(this.content, args);
const result = this.callback(...[methodName, originalResult, ...args] as VirtualDirectoryMockCallbackArgs);
if (chainableMethods.includes(methodName)) {
return this;
}
return result;
};
}
}
}
export type VirtualEntry = VirtualFile | VirtualDirectory
export type VirtualMockedEntry = VirtualEntry | VirtualDirectoryMock
export type VirtualMockedDirectory = VirtualDirectory | VirtualDirectoryMock
export type VirtualDirectory = Map<string, VirtualMockedEntry>
export class VirtualStorageProvider implements IStorageProvider {
static separator: string = "/"
files: VirtualMockedDirectory = new Map<string, VirtualMockedEntry>()
constructor() {
}
normalizePath(targetPath: string): string {
const resolvedPath = path.resolve(targetPath)
return resolvedPath.replaceAll(/\\/g, VirtualStorageProvider.separator)
}
relativePath(targetPath: string, basePath = ".") {
return path.relative(basePath, targetPath)
}
splitPath(path: string): [string, string] {
const normalizedPath = this.normalizePath(path);
const lastSeparatorIndex = normalizedPath.lastIndexOf(VirtualStorageProvider.separator);
if (lastSeparatorIndex === -1) {
return ["", normalizedPath];
}
const dirPath = normalizedPath.substring(0, lastSeparatorIndex);
const entryName = normalizedPath.substring(lastSeparatorIndex + 1);
return [dirPath, entryName];
}
mkDir(path: string): VirtualMockedDirectory {
const normalizedPath = this.normalizePath(path);
if (normalizedPath === "") {
return this.files;
}
const parts = normalizedPath.split(VirtualStorageProvider.separator).filter(p => p !== "");
let currentDir = this.files;
if (parts.length > 0 && /^[A-Za-z]:$/.test(parts[0]!)) {
const diskRoot = parts[0]!;
let diskDir = currentDir.get(diskRoot);
if (!diskDir) {
diskDir = new Map<string, VirtualMockedEntry>();
currentDir.set(diskRoot, diskDir);
} else if (diskDir instanceof VirtualFile) {
throw new Error(`File ${diskRoot} already exists`);
}
currentDir = diskDir;
parts.shift();
}
for (const part of parts) {
let nextDir = currentDir.get(part);
if (nextDir instanceof VirtualFile) {
throw new Error(`File ${part} already exists`);
}
if (!nextDir) {
nextDir = new Map<string, VirtualMockedEntry>();
currentDir.set(part, nextDir);
}
currentDir = nextDir;
}
return currentDir;
}
rm(path: string) {
const normalizedPath = this.normalizePath(path);
const [parentPath, entryName] = this.splitPath(normalizedPath);
if (!entryName) {
throw new Error("Invalid path");
}
const parentDir = parentPath === "" ? this.files : this.getEntry(parentPath);
if (!parentDir) {
throw new Error(`Directory ${parentPath} does not exist`);
}
if (parentDir instanceof VirtualFile) {
throw new Error(`'${parentPath}' is a file but treated as a directory`);
}
parentDir.delete(entryName);
}
getEntry(path: string): VirtualMockedEntry | undefined {
const normalizedPath = this.normalizePath(path);
if (normalizedPath === "") {
return this.files;
}
const parts = normalizedPath.split(VirtualStorageProvider.separator).filter(p => p !== "");
let currentDir = this.files;
const entry = parts.pop()!
for (const part of parts) {
const currentEntry = currentDir.get(part);
if (!currentEntry) {
throw new Error(`${normalizedPath} does not exist`);
}
if (currentEntry instanceof VirtualFile) {
throw new Error(`'${part}' in ${normalizedPath} is a file but treated as a directory`);
}
currentDir = currentEntry;
}
return currentDir.get(entry)
}
async exists(path: string) {
try {
return this.getEntry(path) !== undefined;
} catch {
return false;
}
}
async isFile(path: string) {
try {
return this.getEntry(path) instanceof VirtualFile;
} catch {
return false;
}
}
async readFile(filePath: string): Promise<VirtualFile> {
const entry = this.getEntry(filePath);
if (entry instanceof VirtualFile) {
return entry;
}
const message = entry ? "is not a file" : "does not exist";
throw new Error(`${filePath} ${message}`);
}
async createFile(filePath: string, content: Buffer): Promise<VirtualFile> {
const normalizedPath = this.normalizePath(filePath);
const [dirPath, fileName] = this.splitPath(normalizedPath);
if (!fileName) {
throw new Error("Invalid path");
}
const dir = this.mkDir(dirPath);
const file = new VirtualFile(normalizedPath, content);
dir.set(fileName, file);
return file;
}
async moveFile(sourcePath: string, targetPath: string): Promise<VirtualFile> {
const targetFile = await this.copyFile(sourcePath, targetPath);
await this.deleteFileOrDir(sourcePath);
return targetFile;
}
async copyFile(sourcePath: string, targetPath: string): Promise<VirtualFile> {
const sourceFile = this.getEntry(sourcePath);
if (!sourceFile) {
throw new Error(`${sourcePath} does not exist`);
}
if (!(sourceFile instanceof VirtualFile)) {
throw new Error(`${sourcePath} is not a file`);
}
return await this.createFile(targetPath, sourceFile.data);
}
async isDir(path: string) {
try {
const entry = this.getEntry(path);
return !!entry && !(entry instanceof VirtualFile);
} catch {
return false;
}
}
async readDir(dirPath: string, ignore: string[] = []): Promise<string[]> {
const normalizedPath = this.normalizePath(dirPath);
const entry = this.getEntry(normalizedPath);
if (!entry) {
throw new Error(`'${dirPath}' does not exist`);
}
if (entry instanceof VirtualFile) {
throw new Error(`'${dirPath}' is a file but treated as a directory`);
}
const separator = normalizedPath ? VirtualStorageProvider.separator : "";
const prefix = normalizedPath + separator;
let paths = Array.from(entry.keys()).map(p => this.relativePath(`${prefix}${p}`));
if (ignore.length > 0) {
paths = micromatch.not(paths, ignore, {
dot: true,
basename: false,
cwd: "",
windows: VirtualStorageProvider.separator === "/"
});
}
return paths;
}
async readDirDeep(dirPath: string, ignore: string[] = []): Promise<string[]> {
const normalizedPath = this.normalizePath(dirPath);
const entry = this.getEntry(normalizedPath);
if (!entry) {
throw new Error(`'${dirPath}' does not exist`);
}
if (entry instanceof VirtualFile) {
throw new Error(`'${dirPath}' is a file but treated as a directory`);
}
const separator = normalizedPath ? VirtualStorageProvider.separator : "";
const prefix = normalizedPath + separator;
let paths = Array.from(entry.keys()).map(p => this.relativePath(`${prefix}${p}`))
if (ignore.length > 0) {
paths = micromatch.not(paths, ignore, {
dot: true,
basename: false,
cwd: "",
windows: VirtualStorageProvider.separator === "/"
});
}
const result = [...paths];
for (const path of paths) {
if (await this.isDir(path)) {
result.push(...await this.readDirDeep(path, ignore));
}
}
return result;
}
async createDir(dirPath: string): Promise<string> {
const normalizedPath = this.normalizePath(dirPath);
this.mkDir(normalizedPath);
return this.relativePath(normalizedPath);
}
async mockDir(dirPath: string, mock: VirtualMockedDirectory): Promise<string> {
const normalizedPath = this.normalizePath(dirPath);
const [parentDirPath, dirName] = this.splitPath(normalizedPath);
this.mkDir(parentDirPath).set(dirName, mock);
return this.relativePath(normalizedPath);
}
async deleteFileOrDir(path: string): Promise<void> {
this.rm(path);
}
}