import { FileContent, FileInfo, SearchResult, SearchMatch, Metadata } from '../utils/types.js';
import { FileConflictError, VaultAccessError } from '../utils/errors.js';
import { FileLockManager } from '../core/lock-manager.js';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import fs from 'fs-extra';
import path from 'path';
import { glob } from 'glob';
export class FilesystemProvider {
private markdownParser: MarkdownParser;
constructor(private lockManager: FileLockManager) {
this.markdownParser = new MarkdownParser();
}
async readFile(
vaultPath: string,
filepath: string,
parseFrontmatter: boolean = false
): Promise<FileContent> {
const fullPath = path.join(vaultPath, filepath);
const lock = await this.lockManager.acquireReadLock(fullPath);
try {
const content = await fs.readFile(fullPath, 'utf-8');
const stats = await fs.stat(fullPath);
const result: FileContent = {
content,
path: filepath,
size: stats.size,
modified: stats.mtime,
};
if (parseFrontmatter) {
result.frontmatter = this.markdownParser.extractFrontmatter(content);
}
return result;
} catch (error: any) {
throw new VaultAccessError(`Failed to read file: ${filepath}`, error);
} finally {
lock.release();
}
}
async writeFile(
vaultPath: string,
filepath: string,
content: string,
expectedModTime?: Date
): Promise<void> {
const fullPath = path.join(vaultPath, filepath);
const lock = await this.lockManager.acquireWriteLock(fullPath);
try {
// Conflict detection
if (expectedModTime) {
const hasConflict = await this.detectConflict(fullPath, expectedModTime);
if (hasConflict) {
const actualStats = await fs.stat(fullPath).catch(() => null);
throw new FileConflictError(
`File ${filepath} was modified by another process. ` +
`Expected mod time: ${expectedModTime.toISOString()}, ` +
`actual: ${actualStats?.mtime.toISOString() || 'file not found'}`
);
}
}
// Ensure directory exists
await fs.ensureDir(path.dirname(fullPath));
// Atomic write (write to temp, then rename)
const tempPath = `${fullPath}.tmp.${Date.now()}`;
await fs.writeFile(tempPath, content, 'utf-8');
await fs.rename(tempPath, fullPath);
} catch (error: any) {
if (error instanceof FileConflictError) throw error;
throw new VaultAccessError(`Failed to write file: ${filepath}`, error);
} finally {
lock.release();
}
}
async appendContent(vaultPath: string, filepath: string, content: string): Promise<void> {
const fullPath = path.join(vaultPath, filepath);
const lock = await this.lockManager.acquireWriteLock(fullPath);
try {
await fs.appendFile(fullPath, content, 'utf-8');
} catch (error: any) {
throw new VaultAccessError(`Failed to append to file: ${filepath}`, error);
} finally {
lock.release();
}
}
async listFiles(
vaultPath: string,
subpath?: string,
recursive: boolean = false,
includeHidden: boolean = false
): Promise<FileInfo[]> {
const basePath = subpath ? path.join(vaultPath, subpath) : vaultPath;
try {
const pattern = recursive ? '**/*' : '*';
const ignorePatterns = includeHidden ? [] : ['**/.obsidian/**', '**/.*'];
const files = await glob(pattern, {
cwd: basePath,
ignore: ignorePatterns,
nodir: false,
stat: true,
withFileTypes: true,
});
const fileInfos: FileInfo[] = [];
for (const file of files) {
const fullPath = path.join(basePath, file.name);
const stats = await fs.stat(fullPath);
fileInfos.push({
path: subpath ? path.join(subpath, file.name) : file.name,
name: path.basename(file.name),
isDirectory: stats.isDirectory(),
size: stats.size,
modified: stats.mtime,
created: stats.birthtime,
});
}
return fileInfos;
} catch (error: any) {
throw new VaultAccessError(`Failed to list files in: ${subpath || '/'}`, error);
}
}
async searchFiles(
vaultPath: string,
query: string,
searchPath?: string,
caseSensitive: boolean = false,
useRegex: boolean = false,
contextLength: number = 50
): Promise<SearchResult[]> {
const basePath = searchPath ? path.join(vaultPath, searchPath) : vaultPath;
try {
const files = await glob('**/*.md', {
cwd: basePath,
ignore: ['**/.obsidian/**'],
});
const results: SearchResult[] = [];
const searchRegex = useRegex
? new RegExp(query, caseSensitive ? 'g' : 'gi')
: new RegExp(this.escapeRegex(query), caseSensitive ? 'g' : 'gi');
for (const file of files) {
const fullPath = path.join(basePath, file);
const content = await fs.readFile(fullPath, 'utf-8');
const lines = content.split('\n');
const matches: SearchMatch[] = [];
lines.forEach((line, lineNum) => {
const regex = new RegExp(searchRegex.source, searchRegex.flags);
let match;
while ((match = regex.exec(line)) !== null) {
const start = Math.max(0, match.index - contextLength);
const end = Math.min(line.length, match.index + match[0].length + contextLength);
const context = line.substring(start, end);
matches.push({
line: lineNum + 1,
column: match.index + 1,
text: match[0],
context,
});
}
});
if (matches.length > 0) {
results.push({
path: searchPath ? path.join(searchPath, file) : file,
matches,
score: matches.length,
});
}
}
// Sort by score (number of matches)
results.sort((a, b) => b.score - a.score);
return results;
} catch (error: any) {
throw new VaultAccessError(`Failed to search files`, error);
}
}
async getMetadata(vaultPath: string, filepath: string): Promise<Metadata> {
const fullPath = path.join(vaultPath, filepath);
const lock = await this.lockManager.acquireReadLock(fullPath);
try {
const content = await fs.readFile(fullPath, 'utf-8');
const stats = await fs.stat(fullPath);
const parsed = this.markdownParser.parse(content);
const wordCount = content.split(/\s+/).filter((w) => w.length > 0).length;
return {
frontmatter: parsed.frontmatter,
tags: parsed.tags,
links: parsed.links,
backlinks: [], // Would need to scan all files to find backlinks
wordCount,
created: stats.birthtime,
modified: stats.mtime,
};
} catch (error: any) {
throw new VaultAccessError(`Failed to get metadata for: ${filepath}`, error);
} finally {
lock.release();
}
}
private async detectConflict(fullPath: string, expectedModTime: Date): Promise<boolean> {
try {
const stats = await fs.stat(fullPath);
// Allow 1 second tolerance for filesystem timestamp precision
return Math.abs(stats.mtime.getTime() - expectedModTime.getTime()) > 1000;
} catch (error) {
// File doesn't exist - no conflict
return false;
}
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}