Files
obsidian-livesync/src/apps/cli/adapters/NodeFileSystemAdapter.ts
T
2026-07-17 11:13:16 +00:00

176 lines
6.3 KiB
TypeScript

import type { FilePath, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { IFileSystemAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import { NodePathAdapter } from "./NodePathAdapter";
import { NodeTypeGuardAdapter } from "./NodeTypeGuardAdapter";
import { NodeConversionAdapter } from "./NodeConversionAdapter";
import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import { NodeVaultAdapter } from "./NodeVaultAdapter";
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
/**
* Complete file system adapter implementation for Node.js
*/
export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeFile, NodeFolder, NodeStat> {
readonly path: NodePathAdapter;
readonly typeGuard: NodeTypeGuardAdapter;
readonly conversion: NodeConversionAdapter;
readonly storage: NodeStorageAdapter;
readonly vault: NodeVaultAdapter;
private fileCache = new Map<string, NodeFile>();
constructor(private basePath: string) {
this.path = new NodePathAdapter();
this.typeGuard = new NodeTypeGuardAdapter();
this.conversion = new NodeConversionAdapter();
this.storage = new NodeStorageAdapter(basePath);
this.vault = new NodeVaultAdapter(basePath);
}
private resolvePath(p: FilePath | string): string {
return path.join(this.basePath, p);
}
private normalisePath(p: FilePath | string): string {
return this.path.normalisePath(p as string);
}
private async hasExactPathCase(pathStr: string): Promise<boolean> {
try {
const segments = pathStr.split("/").filter((segment) => segment !== "");
let currentPath = this.basePath;
for (const segment of segments) {
const entries = await fs.readdir(currentPath);
if (!entries.includes(segment)) return false;
currentPath = path.join(currentPath, segment);
}
return segments.length > 0;
} catch {
return false;
}
}
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
if (!this.fileCache.has(pathStr) && !(await this.hasExactPathCase(pathStr))) {
this.fileCache.delete(pathStr);
return null;
}
return await this.refreshFile(pathStr);
}
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
const exact = await this.getAbstractFileByPath(pathStr);
if (exact) {
return exact;
}
const lowerPath = pathStr.toLowerCase();
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
if (cachedPath.toLowerCase() === lowerPath) {
return cachedFile;
}
}
await this.scanDirectory();
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
if (cachedPath.toLowerCase() === lowerPath) {
return cachedFile;
}
}
return null;
}
async getFiles(): Promise<NodeFile[]> {
if (this.fileCache.size === 0) {
await this.scanDirectory();
}
return Array.from(this.fileCache.values());
}
async renameFile(file: NodeFile, newPath: string): Promise<NodeFile> {
const oldPath = file.path;
await this.vault.rename(file, newPath);
this.fileCache.delete(oldPath);
const renamedFile = await this.refreshFile(newPath);
if (!renamedFile) throw new Error(`Could not find renamed file: ${newPath}`);
return renamedFile;
}
async statFromNative(file: NodeFile): Promise<UXStat> {
return file.stat;
}
async reconcileInternalFile(p: string): Promise<void> {
// No-op in Node.js version
// This is used by Obsidian to sync internal file metadata
}
async refreshFile(p: string): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
try {
const fullPath = this.resolvePath(pathStr);
const stat = await fs.stat(fullPath);
if (!stat.isFile()) {
this.fileCache.delete(pathStr);
return null;
}
const file: NodeFile = {
path: pathStr as FilePath,
stat: {
size: stat.size,
mtime: Math.floor(stat.mtimeMs),
ctime: Math.floor(stat.ctimeMs),
type: "file",
},
};
this.fileCache.set(pathStr, file);
return file;
} catch {
// Evict so a deleted file is not returned by subsequent cache scans.
this.fileCache.delete(pathStr);
return null;
}
}
/**
* Helper method to recursively scan directory and populate file cache
*/
async scanDirectory(relativePath: string = ""): Promise<void> {
const fullPath = this.resolvePath(relativePath);
try {
const entries = await fs.readdir(fullPath, { withFileTypes: true });
for (const entry of entries) {
const entryRelativePath = path.join(relativePath, entry.name).replace(/\\/g, "/");
if (entry.isDirectory()) {
await this.scanDirectory(entryRelativePath);
} else if (entry.isFile()) {
const entryFullPath = this.resolvePath(entryRelativePath);
const stat = await fs.stat(entryFullPath);
const file: NodeFile = {
path: entryRelativePath as FilePath,
stat: {
size: stat.size,
mtime: Math.floor(stat.mtimeMs),
ctime: Math.floor(stat.ctimeMs),
type: "file",
},
};
this.fileCache.set(entryRelativePath, file);
}
}
} catch (error) {
// Directory doesn't exist or is not readable
console.error(`Error scanning directory ${fullPath}:`, error);
}
}
}