M0-M11 狩夜人笔记初始实现:11里程碑+86测试

This commit is contained in:
hunter-dev
2026-08-24 20:35:22 +08:00
commit 3ebd33536b
106 changed files with 10490 additions and 0 deletions
@@ -0,0 +1,134 @@
// 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<TreeNode[]> {
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<string> {
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<ReadDocResult> {
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<void> {
const abs = this.abs(payload.path);
const content = joinFrontmatter(payload.meta, payload.body);
return withFileLock(abs, () => writeFileAtomic(abs, content));
}
}