import { LOG_LEVEL_NOTICE, type FilePath, type UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { IFileSystemAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import { FSAPIPathAdapter } from "./FSAPIPathAdapter"; import { FSAPITypeGuardAdapter } from "./FSAPITypeGuardAdapter"; import { FSAPIConversionAdapter } from "./FSAPIConversionAdapter"; import { FileSystemAccessStorageAdapter } from "@vrtmrz/livesync-commonlib/browser"; import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter"; import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes"; import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2"; import type { WebAppLog } from "@/apps/webapp/WebAppLog"; import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup"; /** * Complete file system adapter implementation for FileSystem API */ export class FSAPIFileSystemAdapter implements IFileSystemAdapter { readonly path: FSAPIPathAdapter; readonly typeGuard: FSAPITypeGuardAdapter; readonly conversion: FSAPIConversionAdapter; readonly storage: FileSystemAccessStorageAdapter; readonly vault: FSAPIVaultAdapter; private fileCache = new Map(); private handleCache = new Map(); constructor( private rootHandle: FileSystemDirectoryHandle, private readonly addLog: WebAppLog ) { this.path = new FSAPIPathAdapter(); this.typeGuard = new FSAPITypeGuardAdapter(); this.conversion = new FSAPIConversionAdapter(); this.storage = new FileSystemAccessStorageAdapter(rootHandle); this.vault = new FSAPIVaultAdapter(rootHandle); } private normalisePath(path: FilePath | string): string { return this.path.normalisePath(path); } /** * Get file handle for a given path */ private async getFileHandleByPath(p: FilePath | string): Promise { const pathStr = p; // Check cache first const cached = this.handleCache.get(pathStr); if (cached) return cached; try { const parts = pathStr.split("/").filter((part) => part !== ""); if (parts.length === 0) return null; let currentHandle: FileSystemDirectoryHandle = this.rootHandle; const fileName = parts[parts.length - 1]; // Navigate to the parent directory for (let i = 0; i < parts.length - 1; i++) { currentHandle = await currentHandle.getDirectoryHandle(parts[i]); if (currentHandle.name !== parts[i]) return null; } const fileHandle = await currentHandle.getFileHandle(fileName); if (fileHandle.name !== fileName) return null; this.handleCache.set(pathStr, fileHandle); return fileHandle; } catch { return null; } } async getAbstractFileByPath(p: FilePath | string): Promise { const pathStr = this.normalisePath(p); const cached = this.fileCache.get(pathStr); if (cached) { return cached; } return await this.refreshFile(pathStr); } /** * */ async getAbstractFileByPathInsensitive(p: FilePath | string): Promise { const pathStr = this.normalisePath(p); return await findCaseInsensitiveFile( pathStr, () => this.getAbstractFileByPath(pathStr), this.fileCache, () => this.scanDirectory() ); } async getFiles(): Promise { if (this.fileCache.size === 0) { await this.scanDirectory(); } return Array.from(this.fileCache.values()); } async renameFile(file: FSAPIFile, newPath: string): Promise { await this.vault.rename(file, newPath); this.clearCache(); const renamedFile = await this.refreshFile(newPath); if (!renamedFile) throw new Error(`Could not find renamed file: ${newPath}`); return renamedFile; } async statFromNative(file: FSAPIFile): Promise { // Refresh stat from the file handle try { const fileObject = await file.handle.getFile(); return { size: fileObject.size, mtime: fileObject.lastModified, ctime: fileObject.lastModified, type: "file", }; } catch { return file.stat; } } async reconcileInternalFile(p: string): Promise { // No-op in webapp version // This is used by Obsidian to sync internal file metadata } /** * Refresh file cache for a specific path */ async refreshFile(p: string): Promise { const pathStr = this.normalisePath(p); const handle = await this.getFileHandleByPath(pathStr); if (!handle) { this.fileCache.delete(pathStr); this.handleCache.delete(pathStr); return null; } const fileObject = await handle.getFile(); const file: FSAPIFile = { path: pathStr as FilePath, stat: { size: fileObject.size, mtime: fileObject.lastModified, ctime: fileObject.lastModified, type: "file", }, handle: handle, }; this.fileCache.set(pathStr, file); this.handleCache.set(pathStr, handle); return file; } /** * Helper method to recursively scan directory and populate file cache */ async scanDirectory(relativePath: string = ""): Promise { return shareRunningResult("scanDirectory:" + relativePath, async () => { try { const parts = relativePath.split("/").filter((part) => part !== ""); let currentHandle = this.rootHandle; for (const part of parts) { currentHandle = await currentHandle.getDirectoryHandle(part); } // Use AsyncIterator instead of .values() for better compatibility for await (const [name, entry] of ( currentHandle as unknown as { entries(): AsyncIterable<[string, FileSystemHandle]>; } ).entries()) { const entryPath = relativePath ? `${relativePath}/${name}` : name; if (entry.kind === "directory") { // Recursively scan subdirectories await this.scanDirectory(entryPath); } else if (entry.kind === "file") { const fileHandle = entry as FileSystemFileHandle; const fileObject = await fileHandle.getFile(); const file: FSAPIFile = { path: entryPath as FilePath, stat: { size: fileObject.size, mtime: fileObject.lastModified, ctime: fileObject.lastModified, type: "file", }, handle: fileHandle, }; this.fileCache.set(entryPath, file); this.handleCache.set(entryPath, fileHandle); } } } catch (error) { this.addLog( `Error scanning directory '${relativePath || "."}': ${String(error)}`, LOG_LEVEL_NOTICE, "fsapi-scan" ); } }); } /** * Clear all caches */ clearCache(): void { this.fileCache.clear(); this.handleCache.clear(); } }