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,56 @@
// ConfigStore — 应用配置 JSON 持久化(vault meta、最近库)
import nodePath from 'node:path';
import { promises as fs } from 'node:fs';
import type { VaultMeta } from '@hunter/shared';
import { RECENT_VAULT_MAX } from '@hunter/shared';
export class ConfigStore {
private file: string;
private cache: { vault?: VaultMeta } | null = null;
constructor(userDataDir: string) {
this.file = nodePath.join(userDataDir, 'config.json');
}
private async read(): Promise<{ vault?: VaultMeta }> {
if (this.cache) return this.cache;
try {
const raw = await fs.readFile(this.file, 'utf8');
this.cache = JSON.parse(raw);
} catch {
this.cache = {};
}
return this.cache!;
}
private async write(): Promise<void> {
const dir = nodePath.dirname(this.file);
await fs.mkdir(dir, { recursive: true });
const tmp = this.file + '.tmp';
await fs.writeFile(tmp, JSON.stringify(this.cache, null, 2), 'utf8');
await fs.rename(tmp, this.file);
}
async getVaultMeta(): Promise<VaultMeta> {
const c = await this.read();
return c.vault ?? { currentRoot: '', recents: [], defaultRoot: '' };
}
async setCurrentRoot(root: string): Promise<VaultMeta> {
const meta = await this.getVaultMeta();
meta.currentRoot = root;
// 加入最近库(去重置顶,截断到上限)
meta.recents = [root, ...meta.recents.filter(r => r !== root)].slice(0, RECENT_VAULT_MAX);
(await this.read()).vault = meta;
await this.write();
return meta;
}
async setDefaultRoot(root: string): Promise<VaultMeta> {
const meta = await this.getVaultMeta();
meta.defaultRoot = root;
(await this.read()).vault = meta;
await this.write();
return meta;
}
}
@@ -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));
}
}
@@ -0,0 +1,43 @@
// 知识图谱数据构建(FR-G1/G2/G3):节点=文档,边=关联链接,按顶层分组着色
import type { TreeNode } from '@hunter/shared';
import { splitFrontmatter } from '../safe/frontmatter-codec.js';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
export interface GraphNode { id: string; title: string; basePath: string; star: boolean; group: string; color: string; }
export interface GraphEdge { source: string; target: string; }
export interface GraphData { nodes: GraphNode[]; edges: GraphEdge[]; }
// 12 色轮换
const COLORS = ['#e6194b','#3cb44b','#ffe119','#4363d8','#f58231','#911eb4','#46f0f0','#f032e6','#bcf60c','#fabebe','#008080','#e6beff'];
export async function buildGraphData(root: string, tree: TreeNode[]): Promise<GraphData> {
const docs = tree.filter(n => n.type === 'doc');
const topFolderOf = (p: string) => p.includes('/') ? p.split('/')[0] : '(根目录)';
const groupColor = new Map<string, string>();
const groups = [...new Set(docs.map(d => topFolderOf(d.path)))];
groups.forEach((g, i) => groupColor.set(g, COLORS[i % COLORS.length]));
// 读取每篇文档 frontmatter 取 title/star/links
const nodes: GraphNode[] = [];
const linkMap = new Map<string, string[]>();
for (const d of docs) {
const abs = nodePath.join(root, ...d.path.split('/'));
const raw = await fs.readFile(abs, 'utf8').catch(() => '');
const { meta } = splitFrontmatter(raw);
const group = topFolderOf(d.path);
nodes.push({ id: d.path, title: meta.title ?? d.name, basePath: d.basePath, star: !!meta.star, group, color: groupColor.get(group)! });
if (meta.links) linkMap.set(d.path, meta.links);
}
// 边:仅目标存在且 .md 结尾的关联
const docPaths = new Set(docs.map(d => d.path));
const edges: GraphEdge[] = [];
for (const [src, links] of linkMap) {
for (const l of links) {
const t = l.endsWith('.md') && docPaths.has(l) ? l : (l.endsWith('.md') && docPaths.has(nodePath.join(src, '..', l).split(nodePath.sep).join('/')) ? nodePath.join(src, '..', l).split(nodePath.sep).join('/') : null);
if (t) edges.push({ source: src, target: t });
}
}
return { nodes, edges };
}
@@ -0,0 +1,47 @@
// MigrationService — 旧版数据迁移判定与幂等保护(FR-D1/D2/D3)
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
export interface MigrationResult { migrated: boolean; count: number; reason?: string; }
export class MigrationService {
constructor(private userDataDir: string) {}
private flagFile() { return nodePath.join(this.userDataDir, 'migrated.flag'); }
async isMigrated(): Promise<boolean> {
try { await fs.access(this.flagFile()); return true; } catch { return false; }
}
// 判定是否应迁移:旧数据存在 + 未迁移过 + 新库为空(三者同时满足,FR-D2)
async shouldMigrate(oldDataDir: string, newVaultRoot: string): Promise<boolean> {
if (await this.isMigrated()) return false;
const oldExists = await fs.stat(oldDataDir).then(s => s.isDirectory()).catch(() => false);
if (!oldExists) return false;
// 新库为空(无 .md 文件)
const entries = await fs.readdir(newVaultRoot).catch(() => []);
const hasMd = entries.some(e => e.endsWith('.md'));
return !hasMd;
}
// 执行迁移(骨架:实际板块→文件夹映射在集成时落地)
async migrate(oldDataDir: string, newVaultRoot: string): Promise<MigrationResult> {
if (!(await this.shouldMigrate(oldDataDir, newVaultRoot))) {
return { migrated: false, count: 0, reason: '条件不满足(已迁移/无旧数据/新库非空)' };
}
try {
// TODO: 旧板块三级结构 → 文件夹嵌套;旧富文本 → Markdown;附件 → .attachments;标签与关联映射
// 骨架阶段:仅写迁移标志,count=0
await this.markMigrated();
return { migrated: true, count: 0 };
} catch (e) {
// 失败不记标志(下次可重试,FR-D3)
return { migrated: false, count: 0, reason: e instanceof Error ? e.message : String(e) };
}
}
async markMigrated() {
await fs.mkdir(nodePath.dirname(this.flagFile()), { recursive: true });
await fs.writeFile(this.flagFile(), new Date().toISOString(), 'utf8');
}
}
@@ -0,0 +1,61 @@
// SearchService — 全文搜索 + 标签汇总(FR-R)
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import { SEARCH_RESULT_MAX, MD_EXT, ATTACHMENTS_DIR } from '@hunter/shared';
import type { TreeNode } from '@hunter/shared';
import { splitFrontmatter } from '../safe/frontmatter-codec.js';
export interface SearchHit { path: string; title: string; snippet: string; }
export interface TagStat { tag: string; count: number; }
export class SearchService {
constructor(private root: string, private fileService: { getRoot: () => string }) {}
// 遍历库读取所有文档,建立简易索引(M4:内存全量,大库后续优化)
private async readAll(): Promise<{ path: string; title: string; body: string; tags: string[] }[]> {
const out: { path: string; title: string; body: string; tags: string[] }[] = [];
const walk = async (dirAbs: string, dirRel: string) => {
const entries = await fs.readdir(dirAbs, { withFileTypes: true });
for (const e of entries) {
if (e.name.startsWith('.')) continue;
const abs = nodePath.join(dirAbs, e.name);
const rel = dirRel ? (dirRel + '/' + e.name) : e.name;
if (e.isDirectory()) await walk(abs, rel);
else if (e.isFile() && e.name.endsWith(MD_EXT)) {
const raw = await fs.readFile(abs, 'utf8');
const { meta, body } = splitFrontmatter(raw);
const relPosix = rel.split(nodePath.sep).join('/');
out.push({ path: relPosix, title: meta.title ?? e.name.slice(0, -MD_EXT.length), body, tags: meta.tags ?? [] });
}
}
};
await walk(this.root, '');
return out;
}
async search(query: string): Promise<SearchHit[]> {
if (!query.trim()) return [];
const q = query.toLowerCase();
const all = await this.readAll();
const hits: SearchHit[] = [];
for (const d of all) {
const hay = (d.title + '\n' + d.body).toLowerCase();
const idx = hay.indexOf(q);
if (idx !== -1) {
// 摘要:命中位置附近,超长截尾保留 ~120 字符
const start = Math.max(0, idx - 40);
const snippet = d.body.slice(start, start + 120).replace(/\n/g, ' ');
hits.push({ path: d.path, title: d.title, snippet: (start > 0 ? '…' : '') + snippet });
}
if (hits.length >= SEARCH_RESULT_MAX) break;
}
return hits;
}
async tagStats(): Promise<TagStat[]> {
const all = await this.readAll();
const m = new Map<string, number>();
for (const d of all) for (const t of d.tags) m.set(t, (m.get(t) ?? 0) + 1);
return [...m.entries()].map(([tag, count]) => ({ tag, count })).sort((a, b) => a.tag.localeCompare(b.tag));
}
}
@@ -0,0 +1,53 @@
// VaultService — 统筹库管理:默认库、切换、最近库(FR-V)
import nodePath from 'node:path';
import { promises as fs } from 'node:fs';
import { app, dialog } from 'electron';
import { DEFAULT_VAULT_NAME } from '@hunter/shared';
import type { VaultMeta } from '@hunter/shared';
import { ConfigStore } from './config-store.js';
import { FileService } from './file-service.js';
import { SearchService } from './search-service.js';
export class VaultService {
fileService: FileService | null = null;
searchService: SearchService | null = null;
private config: ConfigStore;
constructor() {
this.config = new ConfigStore(app.getPath('userData'));
}
// 启动时初始化:确保有当前库(无则创建默认库)
async init(): Promise<VaultMeta> {
let meta = await this.config.getVaultMeta();
if (!meta.currentRoot) {
const defaultRoot = nodePath.join(app.getPath('userData'), DEFAULT_VAULT_NAME);
await fs.mkdir(defaultRoot, { recursive: true });
meta = await this.config.setCurrentRoot(defaultRoot);
}
this.fileService = new FileService(meta.currentRoot);
this.searchService = new SearchService(meta.currentRoot, this.fileService);
return meta;
}
async getMeta(): Promise<VaultMeta> { return this.config.getVaultMeta(); }
// 通过系统对话框选择并切换库(FR-V3)
async pickAndSwitch(): Promise<VaultMeta | null> {
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
if (result.canceled || result.filePaths.length === 0) return null;
return this.switch(result.filePaths[0]);
}
async switch(root: string): Promise<VaultMeta> {
// 校验是存在的目录
const stat = await fs.stat(root).catch(() => null);
if (!stat || !stat.isDirectory()) throw new Error('目标路径不是有效目录: ' + root);
const meta = await this.config.setCurrentRoot(root);
this.fileService = new FileService(root);
this.searchService = new SearchService(root, this.fileService);
return meta;
}
async setDefaultRoot(root: string): Promise<void> { await this.config.setDefaultRoot(root); }
}