import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as path from 'path';
import { FileMetadata } from './types.js';
import { VaultError, ERROR_CODES } from './utils/errors.js';
export class VaultAccess {
private vaultPath: string;
constructor(vaultPath: string) {
this.vaultPath = vaultPath;
this.validateVaultPath();
}
private validateVaultPath(): void {
if (!fsSync.existsSync(this.vaultPath)) {
throw new Error(`Vault path does not exist: ${this.vaultPath}`);
}
const stats = fsSync.statSync(this.vaultPath);
if (!stats.isDirectory()) {
throw new Error(`Vault path is not a directory: ${this.vaultPath}`);
}
const obsidianFolder = path.join(this.vaultPath, '.obsidian');
if (!fsSync.existsSync(obsidianFolder)) {
throw new Error(
`Not a valid Obsidian vault (missing .obsidian folder): ${this.vaultPath}`
);
}
}
resolvePath(relativePath: string): string {
const normalized = relativePath.replace(/^\.?\//, '');
if (normalized.includes('..')) {
throw new VaultError(
'Path contains .. segments',
ERROR_CODES.PATH_OUTSIDE_VAULT,
{ relativePath }
);
}
const absolutePath = path.join(this.vaultPath, normalized);
const realVaultPath = fsSync.realpathSync(this.vaultPath);
const realTargetPath = path.resolve(absolutePath);
if (!realTargetPath.startsWith(realVaultPath)) {
throw new VaultError(
'Path escapes vault boundary',
ERROR_CODES.PATH_OUTSIDE_VAULT,
{ relativePath, resolvedPath: realTargetPath }
);
}
return realTargetPath;
}
async readFile(relativePath: string): Promise<string> {
const absolutePath = this.resolvePath(relativePath);
try {
const content = await fs.readFile(absolutePath, 'utf-8');
return content;
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
throw new VaultError(
`File not found: ${relativePath}`,
ERROR_CODES.FILE_NOT_FOUND,
{ path: relativePath }
);
}
throw new VaultError(
`Failed to read file: ${err.message}`,
'READ_ERROR',
{ path: relativePath }
);
}
}
async writeFile(relativePath: string, content: string): Promise<void> {
const absolutePath = this.resolvePath(relativePath);
const maxRetries = 3;
const retryDelay = 100;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const tempPath = `${absolutePath}.tmp`;
await fs.writeFile(tempPath, content, 'utf-8');
await fs.rename(tempPath, absolutePath);
return;
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if (
(err.code === 'EBUSY' || err.code === 'EPERM') &&
attempt < maxRetries - 1
) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
continue;
}
if (err.code === 'EBUSY' || err.code === 'EPERM') {
throw new VaultError(
`File is locked or in use: ${relativePath}`,
ERROR_CODES.FILE_LOCKED,
{ path: relativePath, attempt }
);
}
throw new VaultError(
`Failed to write file: ${err.message}`,
ERROR_CODES.WRITE_ERROR,
{ path: relativePath }
);
}
}
}
async deleteFile(relativePath: string): Promise<void> {
const absolutePath = this.resolvePath(relativePath);
try {
await fs.unlink(absolutePath);
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
throw new VaultError(
`File not found: ${relativePath}`,
ERROR_CODES.FILE_NOT_FOUND,
{ path: relativePath }
);
}
throw new VaultError(
`Failed to delete file: ${err.message}`,
ERROR_CODES.DELETE_ERROR,
{ path: relativePath }
);
}
}
async moveFile(oldPath: string, newPath: string): Promise<void> {
const oldAbsolute = this.resolvePath(oldPath);
const newAbsolute = this.resolvePath(newPath);
try {
await fs.rename(oldAbsolute, newAbsolute);
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
throw new VaultError(
`File not found: ${oldPath}`,
ERROR_CODES.FILE_NOT_FOUND,
{ path: oldPath }
);
}
throw new VaultError(
`Failed to move file: ${err.message}`,
ERROR_CODES.MOVE_ERROR,
{ oldPath, newPath }
);
}
}
async fileExists(relativePath: string): Promise<boolean> {
try {
const absolutePath = this.resolvePath(relativePath);
await fs.access(absolutePath);
return true;
} catch {
return false;
}
}
async getFileStats(relativePath: string): Promise<FileMetadata> {
const absolutePath = this.resolvePath(relativePath);
try {
const stats = await fs.stat(absolutePath);
return {
path: relativePath,
size: stats.size,
modified: stats.mtime,
created: stats.birthtime,
};
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
throw new VaultError(
`File not found: ${relativePath}`,
ERROR_CODES.FILE_NOT_FOUND,
{ path: relativePath }
);
}
throw new VaultError(
`Failed to get file stats: ${err.message}`,
'STAT_ERROR',
{ path: relativePath }
);
}
}
async listFiles(folder?: string, pattern?: string): Promise<FileMetadata[]> {
const searchPath = folder
? this.resolvePath(folder)
: this.vaultPath;
const files: FileMetadata[] = [];
async function walkDir(dir: string, basePath: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === '.obsidian') continue;
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(basePath, fullPath);
if (entry.isDirectory()) {
await walkDir(fullPath, basePath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
if (pattern && !entry.name.includes(pattern)) continue;
const stats = await fs.stat(fullPath);
files.push({
path: relativePath,
size: stats.size,
modified: stats.mtime,
created: stats.birthtime,
});
}
}
}
await walkDir(searchPath, this.vaultPath);
return files;
}
async createFolder(relativePath: string): Promise<void> {
const absolutePath = this.resolvePath(relativePath);
try {
await fs.mkdir(absolutePath, { recursive: true });
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
throw new VaultError(
`Failed to create folder: ${err.message}`,
'FOLDER_CREATE_ERROR',
{ path: relativePath }
);
}
}
getVaultPath(): string {
return this.vaultPath;
}
}