M0-M11 狩夜人笔记初始实现:11里程碑+86测试
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user