Files
obsidian-livesync/src/apps/webapp/adapters/FSAPIVaultAdapter.ts
T

187 lines
7.0 KiB
TypeScript

import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import type { FSAPIFile, FSAPIFolder } from "./FSAPITypes";
/**
* Vault adapter implementation for FileSystem API
*/
export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
constructor(private rootHandle: FileSystemDirectoryHandle) {}
async read(file: FSAPIFile): Promise<string> {
const fileObject = await file.handle.getFile();
return await fileObject.text();
}
async cachedRead(file: FSAPIFile): Promise<string> {
// No caching in webapp version, just read directly
return await this.read(file);
}
async readBinary(file: FSAPIFile): Promise<ArrayBuffer> {
const fileObject = await file.handle.getFile();
return await fileObject.arrayBuffer();
}
async modify(file: FSAPIFile, data: string, options?: UXDataWriteOptions): Promise<void> {
const writable = await file.handle.createWritable();
await writable.write(data);
await writable.close();
}
async modifyBinary(file: FSAPIFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
const writable = await file.handle.createWritable();
await writable.write(data);
await writable.close();
}
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
const parts = p.split("/").filter((part) => part !== "");
const fileName = parts[parts.length - 1];
// Navigate to parent directory, creating as needed
let currentHandle = this.rootHandle;
for (let i = 0; i < parts.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
}
// Create the file
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
// Get file metadata
const fileObject = await fileHandle.getFile();
return {
path: p as FilePath,
stat: {
size: fileObject.size,
mtime: fileObject.lastModified,
ctime: fileObject.lastModified,
type: "file",
},
handle: fileHandle,
};
}
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
const parts = p.split("/").filter((part) => part !== "");
const fileName = parts[parts.length - 1];
// Navigate to parent directory, creating as needed
let currentHandle = this.rootHandle;
for (let i = 0; i < parts.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
}
// Create the file
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
// Get file metadata
const fileObject = await fileHandle.getFile();
return {
path: p as FilePath,
stat: {
size: fileObject.size,
mtime: fileObject.lastModified,
ctime: fileObject.lastModified,
type: "file",
},
handle: fileHandle,
};
}
private async deletePathIfExists(path: string): Promise<void> {
const parts = path.split("/").filter((part) => part !== "");
const name = parts.pop();
if (!name) return;
try {
let currentHandle = this.rootHandle;
for (const part of parts) {
currentHandle = await currentHandle.getDirectoryHandle(part);
}
await currentHandle.removeEntry(name);
} catch (error) {
if ((error as { name?: unknown })?.name === "NotFoundError") return;
throw error;
}
}
async rename(file: FSAPIFile, newPath: string): Promise<void> {
const source = await file.handle.getFile();
const data = await source.arrayBuffer();
const oldPath = file.path;
const oldPathParts = oldPath.split("/");
const oldName = oldPathParts.pop() ?? "file";
const temporaryName = `.${oldName}.livesync-rename-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
const temporaryPath = [...oldPathParts, temporaryName].join("/");
const temporaryFile = await this.createBinary(temporaryPath, data);
try {
await this.delete(file);
} catch (error) {
await this.delete(temporaryFile, true);
throw error;
}
try {
const renamedFile = await this.createBinary(newPath, data);
file.path = renamedFile.path;
file.stat = renamedFile.stat;
file.handle = renamedFile.handle;
} catch (error) {
try {
await this.deletePathIfExists(newPath);
} catch (cleanupError) {
throw new Error(
`Could not rename ${oldPath} to ${newPath}, or remove the incomplete target. A temporary copy remains at ${temporaryPath}. Rename error: ${String(error)}. Cleanup error: ${String(cleanupError)}`
);
}
try {
const restoredFile = await this.createBinary(oldPath, data);
file.path = restoredFile.path;
file.stat = restoredFile.stat;
file.handle = restoredFile.handle;
await this.delete(temporaryFile, true);
} catch (restoreError) {
throw new Error(
`Could not rename ${oldPath} to ${newPath}, or restore it. A temporary copy remains at ${temporaryPath}. Rename error: ${String(error)}. Restore error: ${String(restoreError)}`
);
}
throw error;
}
await this.delete(temporaryFile, true);
}
async delete(file: FSAPIFile | FSAPIFolder, force = false): Promise<void> {
const parts = file.path.split("/").filter((part) => part !== "");
const name = parts[parts.length - 1];
// Navigate to parent directory
let currentHandle = this.rootHandle;
for (let i = 0; i < parts.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(parts[i]);
}
// Remove the entry
await currentHandle.removeEntry(name, { recursive: force });
}
async trash(file: FSAPIFile | FSAPIFolder, force = false): Promise<void> {
// In webapp, trash is the same as delete (no recycle bin)
await this.delete(file, force);
}
trigger(name: string, ...data: unknown[]): void {
// No-op in webapp version (no event system yet)
}
}