// FileService — 库内 CRUD + 文档读写(FR-V/F/M) import nodePath from 'node:path'; import { promises as fs } from 'node:fs'; import { MD_EXT, ATTACHMENTS_DIR } from '@hunter/shared'; import type { TreeNode, ReadDocResult, WriteDocPayload } from '@hunter/shared'; import { assertInsideVault, dedupeName, sanitizeName } from '../safe/safe-path.js'; import { writeFileAtomic, withFileLock } from '../safe/atomic-write.js'; import { splitFrontmatter, joinFrontmatter } from '../safe/frontmatter-codec.js'; // 相对路径统一用 POSIX 分隔符(跨平台一致,利于存储与比较) const toPosix = (p: string): string => p.split(nodePath.sep).join('/').replace(/\//g, '/'); const joinRel = (...parts: string[]): string => toPosix(nodePath.join(...parts)); export class FileService { constructor(private root: string) {} setRoot(root: string) { this.root = root; } getRoot(): string { return this.root; } private abs(rel: string): string { return assertInsideVault(this.root, rel); } // 递归列举库内树(FR-V1/FR-V9) async listTree(): Promise { const out: TreeNode[] = []; const root = this.root; const walk = async (dirAbs: string, dirRel: string) => { let entries = await fs.readdir(dirAbs, { withFileTypes: true }); entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); const folders = entries.filter(e => e.isDirectory()); const docs = entries.filter(e => e.isFile() && e.name.endsWith(MD_EXT)); for (const e of folders) { if (e.name === ATTACHMENTS_DIR || e.name.startsWith('.')) continue; const rel = dirRel ? joinRel(dirRel, e.name) : e.name; const stat = await fs.stat(nodePath.join(dirAbs, e.name)); out.push({ path: rel, type: 'folder', name: e.name, parent: dirRel, basePath: rel, mtime: stat.mtimeMs }); await walk(nodePath.join(dirAbs, e.name), rel); } for (const e of docs) { const rel = dirRel ? joinRel(dirRel, e.name) : e.name; const stat = await fs.stat(nodePath.join(dirAbs, e.name)); out.push({ path: rel, type: 'doc', name: e.name.slice(0, -MD_EXT.length), parent: dirRel, basePath: dirRel, mtime: stat.mtimeMs }); } }; await walk(root, ''); return out; } async create(basePath: string, rawName: string, isFolder: boolean) { const name = sanitizeName(rawName) + (isFolder ? '' : MD_EXT); const finalName = await dedupeName(this.root, basePath, name); const rel = basePath ? joinRel(basePath, finalName) : finalName; const abs = this.abs(rel); if (isFolder) await fs.mkdir(abs, { recursive: true }); else await writeFileAtomic(abs, ''); return { path: rel }; } async rename(from: string, toName: string) { const abs = this.abs(from); const isFolder = (await fs.stat(abs)).isDirectory(); const name = sanitizeName(toName) + (isFolder ? '' : MD_EXT); const dir = nodePath.dirname(from); const finalName = await dedupeName(this.root, dir, name); const toRel = dir ? joinRel(dir, finalName) : finalName; const toAbs = this.abs(toRel); await fs.rename(abs, toAbs); return { path: toRel }; } async move(src: string, destDir: string) { const srcAbs = this.abs(src); const name = nodePath.basename(src); // 禁止移动到自身内部(FR-F9) if (destDir === src || destDir.startsWith(src + nodePath.sep) || (src.startsWith(destDir + nodePath.sep) && destDir)) { throw new Error('cannot move folder into itself'); } const finalName = await dedupeName(this.root, destDir, name); const toRel = destDir ? joinRel(destDir, finalName) : finalName; const toAbs = this.abs(toRel); await fs.mkdir(this.abs(destDir || '.'), { recursive: true }); await fs.rename(srcAbs, toAbs); return { path: toRel }; } async delete(path: string, recursive: boolean) { const abs = this.abs(path); if (recursive) await fs.rm(abs, { recursive: true, force: true }); else await fs.unlink(abs); } async importFiles(sources: string[], destDir: string) { const ok: string[] = []; const failed: { path: string; reason: string }[] = []; for (const src of sources) { try { const name = nodePath.basename(src); // 递归复制 const dest = await this.copyEntry(src, destDir, name); ok.push(dest); } catch (e) { failed.push({ path: src, reason: e instanceof Error ? e.message : String(e) }); } } return { ok, failed }; } private async copyEntry(srcAbs: string, destDirRel: string, name: string): Promise { const stat = await fs.stat(srcAbs); const finalName = await dedupeName(this.root, destDirRel, name); const destRel = destDirRel ? joinRel(destDirRel, finalName) : finalName; const destAbs = this.abs(destRel); if (stat.isDirectory()) { await fs.mkdir(destAbs, { recursive: true }); const entries = await fs.readdir(srcAbs, { withFileTypes: true }); for (const e of entries) { await this.copyEntry(nodePath.join(srcAbs, e.name), destRel, e.name); } } else { await fs.copyFile(srcAbs, destAbs); } return destRel; } async readDoc(path: string): Promise { const abs = this.abs(path); const raw = await fs.readFile(abs, 'utf8'); const { meta, body } = splitFrontmatter(raw); return { meta, body }; } async writeDoc(payload: WriteDocPayload): Promise { const abs = this.abs(payload.path); const content = joinFrontmatter(payload.meta, payload.body); return withFileLock(abs, () => writeFileAtomic(abs, content)); } }