Files
MyNote/apps/desktop/src/main/services/file-service.ts
T

135 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));
}
}