M0-M11 狩夜人笔记初始实现:11里程碑+86测试
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
// main 进程入口(M1:创建窗口 + 初始化库 + 注册 IPC)
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import nodePath from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { VaultService } from './services/vault-service.js';
|
||||
import { registerIpc } from './ipc/handlers.js';
|
||||
import { IPC } from '@hunter/shared';
|
||||
|
||||
const __dirname = nodePath.dirname(fileURLToPath(import.meta.url));
|
||||
let win: BrowserWindow | null = null;
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1200, height: 760, minWidth: 900, minHeight: 580,
|
||||
center: true, frame: false, show: false,
|
||||
webPreferences: {
|
||||
preload: nodePath.join(__dirname, '../preload/index.mjs'),
|
||||
contextIsolation: true, nodeIntegration: false, sandbox: false
|
||||
}
|
||||
});
|
||||
win.on('ready-to-show', () => win?.show());
|
||||
win.on('closed', () => { win = null; });
|
||||
// 窗口控制(FR-W2)
|
||||
ipcMain.on(IPC.winMinimize, () => win?.minimize());
|
||||
ipcMain.on(IPC.winMaximize, () => { if (win?.isMaximized()) win.unmaximize(); else win?.maximize(); });
|
||||
ipcMain.on(IPC.winClose, () => win?.close());
|
||||
if (process.env.ELECTRON_RENDERER_URL) win.loadURL(process.env.ELECTRON_RENDERER_URL);
|
||||
else win.loadFile(nodePath.join(__dirname, '../renderer/index.html'));
|
||||
return win;
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const vault = new VaultService();
|
||||
await vault.init(); // 确保有当前库
|
||||
registerIpc(vault);
|
||||
createWindow();
|
||||
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
||||
});
|
||||
|
||||
// 关闭前冲刷:渲染层监听窗口 close 事件自行 flush(FR-S4),M1 先以关闭前 onbeforeunload 兜底
|
||||
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
|
||||
@@ -0,0 +1,65 @@
|
||||
// IPC handlers — 主进程侧通道注册(类型链约束于 @hunter/shared)
|
||||
import nodePath from 'node:path';
|
||||
import { ipcMain, shell, dialog, clipboard } from 'electron';
|
||||
import { IPC } from '@hunter/shared';
|
||||
import type { VaultService } from '../services/vault-service.js';
|
||||
import { buildGraphData } from '../services/graph-builder.js';
|
||||
|
||||
export function registerIpc(vault: VaultService) {
|
||||
const fs2 = () => {
|
||||
if (!vault.fileService) throw new Error('vault not initialized');
|
||||
return vault.fileService;
|
||||
};
|
||||
|
||||
ipcMain.handle(IPC.vaultList, async () => vault.getMeta());
|
||||
ipcMain.handle(IPC.vaultSwitch, async (_e, p: { root: string }) => vault.switch(p.root));
|
||||
ipcMain.handle(IPC.vaultSetDefault, async (_e, p: { root: string }) => { await vault.setDefaultRoot(p.root); });
|
||||
ipcMain.handle(IPC.vaultPick, async () => vault.pickAndSwitch());
|
||||
|
||||
ipcMain.handle(IPC.treeList, async () => fs2().listTree());
|
||||
ipcMain.handle(IPC.treeRefresh, async () => fs2().listTree());
|
||||
|
||||
ipcMain.handle(IPC.fileCreate, async (_e, p: { basePath: string; name: string; isFolder: boolean }) =>
|
||||
fs2().create(p.basePath, p.name, p.isFolder));
|
||||
ipcMain.handle(IPC.fileRename, async (_e, p: { from: string; toName: string }) =>
|
||||
fs2().rename(p.from, p.toName));
|
||||
ipcMain.handle(IPC.fileMove, async (_e, p: { src: string; destDir: string }) =>
|
||||
fs2().move(p.src, p.destDir));
|
||||
ipcMain.handle(IPC.fileDelete, async (_e, p: { path: string; recursive: boolean }) =>
|
||||
fs2().delete(p.path, p.recursive));
|
||||
ipcMain.handle(IPC.fileImport, async (_e, p: { sources: string[]; destDir: string }) => {
|
||||
// 若 sources 为空,弹出系统选择对话框
|
||||
let sources = p.sources;
|
||||
if (sources.length === 0) {
|
||||
const r = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'], filters: [{ name: 'Markdown', extensions: ['md'] }] });
|
||||
if (r.canceled) return { ok: [], failed: [] };
|
||||
sources = r.filePaths;
|
||||
}
|
||||
return fs2().importFiles(sources, p.destDir);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.docRead, async (_e, p: { path: string }) => fs2().readDoc(p.path));
|
||||
ipcMain.handle(IPC.docWrite, async (_e, p: { path: string; meta: any; body: string }) =>
|
||||
fs2().writeDoc({ path: p.path, meta: p.meta, body: p.body }));
|
||||
ipcMain.handle(IPC.search, async (_e, p: { query: string }) => vault.searchService?.search(p.query) ?? []);
|
||||
ipcMain.handle(IPC.tagStats, async () => vault.searchService?.tagStats() ?? []);
|
||||
ipcMain.handle(IPC.graph, async () => {
|
||||
const fs2 = vault.fileService;
|
||||
if (!fs2) return { nodes: [], edges: [] };
|
||||
const tree = await fs2.listTree();
|
||||
return buildGraphData(fs2.getRoot(), tree);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.copyMd, async (_e, p: { path: string }) => {
|
||||
const fs2 = vault.fileService; if (!fs2) return '';
|
||||
const { body } = await fs2.readDoc(p.path);
|
||||
clipboard.writeText(body);
|
||||
return body;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.docReveal, async (_e, p: { path: string }) => {
|
||||
const fs2 = vault.fileService;
|
||||
if (!fs2) return;
|
||||
shell.showItemInFolder(nodePath.join(fs2.getRoot(), p.path));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// AtomicWrite — 先写临时文件再整体替换,防中断写坏原文件(FR-S5)
|
||||
import nodePath from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { TMP_SUFFIX } from '@hunter/shared';
|
||||
|
||||
export async function writeFileAtomic(absPath: string, content: string): Promise<void> {
|
||||
const tmp = absPath + TMP_SUFFIX;
|
||||
// 确保父目录存在(新文件或跨目录保存时父目录可能尚未创建)
|
||||
await fs.mkdir(nodePath.dirname(absPath), { recursive: true });
|
||||
// 写临时文件并 fsync,保证落盘
|
||||
const handle = await fs.open(tmp, 'w', 0o666);
|
||||
try {
|
||||
await handle.writeFile(content, 'utf8');
|
||||
await handle.sync(); // 强制刷盘
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
// 同卷 rename 原子替换
|
||||
await fs.rename(tmp, absPath);
|
||||
}
|
||||
|
||||
// 并发安全:每文件一个串行队列,防止防抖保存与冲刷交错
|
||||
const queues = new Map<string, Promise<unknown>>();
|
||||
export function withFileLock<T>(absPath: string, task: () => Promise<T>): Promise<T> {
|
||||
const prev = queues.get(absPath) ?? Promise.resolve();
|
||||
const next = prev.then(task, task); // 无论成功失败都继续
|
||||
queues.set(absPath, next.then(() => undefined, () => undefined));
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// FrontmatterCodec — 文件头 frontmatter 解析/序列化(FR-M1/M2)
|
||||
// 仅 4 个业务字段:title? / tags? / star? / links?;空数组与默认值不落盘
|
||||
import type { DocMeta } from './doc.js';
|
||||
|
||||
const FENCE = '---';
|
||||
|
||||
// 解析:返回 { meta, body };无 frontmatter 时 meta={} body=原文
|
||||
export function splitFrontmatter(raw: string): { meta: DocMeta; body: string } {
|
||||
// 必须以 --- 开头(允许前导空白行后紧跟围栏)
|
||||
const m = raw.match(/^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!m) return { meta: {}, body: raw };
|
||||
const yaml = m[1];
|
||||
const body = m[2];
|
||||
return { meta: parseYaml(yaml), body };
|
||||
}
|
||||
|
||||
// 序列化:把 meta + body 合并为完整文件文本;默认值与空数组不写入
|
||||
export function joinFrontmatter(meta: DocMeta, body: string): string {
|
||||
const yaml = serializeYaml(meta);
|
||||
if (!yaml) return body;
|
||||
return `${FENCE}\n${yaml}${FENCE}\n${body}`;
|
||||
}
|
||||
|
||||
// ---- 极简 YAML 子集解析(只支持本项目 4 字段的形态)----
|
||||
function parseYaml(yaml: string): DocMeta {
|
||||
const meta: DocMeta = {};
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
const kv = line.match(/^([a-zA-Z]+):\s*(.*)$/);
|
||||
if (!kv) continue;
|
||||
const [, key, val] = kv;
|
||||
const v = val.trim();
|
||||
if (key === 'title') meta.title = unquote(v);
|
||||
else if (key === 'star') meta.star = v === 'true';
|
||||
else if (key === 'tags') meta.tags = parseList(v);
|
||||
else if (key === 'links') meta.links = parseList(v);
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
function parseList(v: string): string[] {
|
||||
// 内联数组 [a, b] 或流式;本项目只用内联
|
||||
const m = v.match(/^\[\s*(.*)\s*\]$/);
|
||||
if (!m) return [];
|
||||
const inner = m[1].trim();
|
||||
if (!inner) return [];
|
||||
return inner.split(',').map(s => unquote(s.trim())).filter(Boolean);
|
||||
}
|
||||
|
||||
function unquote(s: string): string {
|
||||
const m = s.match(/^['"](.*)['"]$/);
|
||||
return m ? m[1] : s;
|
||||
}
|
||||
|
||||
// ---- 序列化:剔除默认值与空数组 ----
|
||||
function serializeYaml(meta: DocMeta): string {
|
||||
const lines: string[] = [];
|
||||
if (meta.title !== undefined && meta.title !== '') lines.push(`title: ${quoteIfNeeded(meta.title)}`);
|
||||
if (meta.tags && meta.tags.length > 0) lines.push(`tags: [${meta.tags.map(quoteIfNeeded).join(', ')}]`);
|
||||
if (meta.star === true) lines.push('star: true');
|
||||
if (meta.links && meta.links.length > 0) lines.push(`links: [${meta.links.map(quoteIfNeeded).join(', ')}]`);
|
||||
return lines.length ? lines.join('\n') + '\n' : '';
|
||||
}
|
||||
|
||||
function quoteIfNeeded(s: string): string {
|
||||
// 含特殊字符(冒号/井号/方括号/引号/前后空格)则加引号
|
||||
if (/[:#\[\]"']|^\s|\s$/.test(s)) return JSON.stringify(s);
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// SafePath — 将所有文件操作圈禁在库根之内
|
||||
// 对应需求:FR-F15 路径严格限定在库根范围内
|
||||
import nodePath from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
|
||||
export class IllegalPathError extends Error {
|
||||
constructor(target: string) {
|
||||
super(`path escapes vault root: ${target}`);
|
||||
this.name = 'IllegalPathError';
|
||||
}
|
||||
}
|
||||
|
||||
// 非法字符净化:把系统不允许的字符替换为下划线(FR-F13)
|
||||
const ILLEGAL = /[<>:"/\\|?*\u0000-\u001f]/g;
|
||||
export function sanitizeName(name: string): string {
|
||||
let n = name.replace(ILLEGAL, '_').trim();
|
||||
if (!n) n = '未命名';
|
||||
return n;
|
||||
}
|
||||
|
||||
// 相对路径越界校验:解析后不得逃出库根
|
||||
export function assertInsideVault(root: string, rel: string): string {
|
||||
// 拒绝绝对路径与盘符前缀
|
||||
if (nodePath.isAbsolute(rel) || /^[a-zA-Z]:/.test(rel)) {
|
||||
throw new IllegalPathError(rel);
|
||||
}
|
||||
const resolved = nodePath.resolve(root, rel);
|
||||
const relFromRoot = nodePath.relative(root, resolved);
|
||||
if (relFromRoot.startsWith('..') || nodePath.isAbsolute(relFromRoot)) {
|
||||
throw new IllegalPathError(rel);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// 防 symlink 逃逸:解析真实路径后再校验一次
|
||||
export async function assertRealInsideVault(root: string, rel: string): Promise<string> {
|
||||
const resolved = assertInsideVault(root, rel);
|
||||
const rootReal = await fs.realpath(root).catch(() => root);
|
||||
const real = await fs.realpath(resolved).catch(() => resolved);
|
||||
const relFromRoot = nodePath.relative(rootReal, real);
|
||||
if (relFromRoot.startsWith('..') || nodePath.isAbsolute(relFromRoot)) {
|
||||
throw new IllegalPathError(rel);
|
||||
}
|
||||
return real;
|
||||
}
|
||||
|
||||
// 重名自动追加序号(FR-F14):在 destDir 内为 name 寻找不冲突名
|
||||
export async function dedupeName(root: string, dir: string, name: string): Promise<string> {
|
||||
const base = name;
|
||||
const ext = nodePath.extname(name);
|
||||
const stem = ext ? base.slice(0, -ext.length) : base;
|
||||
let candidate = name;
|
||||
let i = 1;
|
||||
const dirAbs = nodePath.resolve(root, dir);
|
||||
for (;;) {
|
||||
try {
|
||||
await fs.access(nodePath.join(dirAbs, candidate)); // 存在则继续
|
||||
candidate = `${stem}-${i}${ext}`;
|
||||
i++;
|
||||
} catch {
|
||||
return candidate; // 不存在,可用
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// 智能取色:分析背景图亮度/主色 → 推荐深浅模式与强调色(FR-A9)
|
||||
// 纯函数,输入采样后的像素数据,输出推荐参数
|
||||
|
||||
export interface ImageSample {
|
||||
/** 采样像素的 [r,g,b] 列表(已降采样) */
|
||||
pixels: [number, number, number][];
|
||||
}
|
||||
|
||||
export interface SmartColorResult {
|
||||
mode: 'dark' | 'light';
|
||||
accentHue: number; // 0-360,约束在冷色/紫红区间
|
||||
accent: string; // hex 强调色
|
||||
suggestedBgOpacity: number; // 0-1
|
||||
suggestedBlur: number; // px
|
||||
suggestedSurfaceOpacity: number; // 0-1
|
||||
}
|
||||
|
||||
// 相对亮度(Rec.709)
|
||||
function luminance(r: number, g: number, b: number): number {
|
||||
const f = (c: number) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h = 0; const l = (max + min) / 2; const d = max - min; const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
||||
if (d !== 0) {
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d) % 6; break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
default: h = (r - g) / d + 4;
|
||||
}
|
||||
h *= 60; if (h < 0) h += 360;
|
||||
}
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0];
|
||||
else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c];
|
||||
else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x];
|
||||
const to = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0');
|
||||
return `#${to(r)}${to(g)}${to(b)}`;
|
||||
}
|
||||
|
||||
// 色相约束:冷色(180-270) 与紫红(280-330) 区间,否则就近映射
|
||||
function constrainHue(h: number): number {
|
||||
if (h >= 180 && h <= 330) return h;
|
||||
// 映射到最近区间端点
|
||||
if (h < 180) return h < 90 ? 200 : 210; // 暖色偏冷蓝
|
||||
return 290; // >330 映射紫红
|
||||
}
|
||||
|
||||
export function analyzeImage(sample: ImageSample): SmartColorResult {
|
||||
const { pixels } = sample;
|
||||
if (pixels.length === 0) {
|
||||
return { mode: 'dark', accentHue: 210, accent: '#4a9eff', suggestedBgOpacity: 0.6, suggestedBlur: 20, suggestedSurfaceOpacity: 0.5 };
|
||||
}
|
||||
// 平均亮度
|
||||
const avgLum = pixels.reduce((s, p) => s + luminance(p[0], p[1], p[2]), 0) / pixels.length;
|
||||
const mode: 'dark' | 'light' = avgLum < 0.5 ? 'dark' : 'light';
|
||||
// 主色相:色相直方图(12 桶),取高频且饱和度足够
|
||||
const buckets = new Array(12).fill(0);
|
||||
let dominantHue = 210;
|
||||
for (const p of pixels) {
|
||||
const [h, s] = rgbToHsl(p[0], p[1], p[2]);
|
||||
if (s > 0.15) buckets[Math.floor(h / 30) % 12]++;
|
||||
}
|
||||
let max = 0;
|
||||
for (let i = 0; i < 12; i++) if (buckets[i] > max) { max = buckets[i]; dominantHue = i * 30 + 15; }
|
||||
const accentHue = constrainHue(dominantHue);
|
||||
const accent = hslToHex(accentHue, 0.7, mode === 'dark' ? 0.6 : 0.45);
|
||||
// 依亮度推导不透明度
|
||||
const suggestedBgOpacity = Math.round(Math.max(0.3, Math.min(0.8, 0.5 + (0.5 - avgLum) * 0.4)) * 100) / 100;
|
||||
const suggestedBlur = avgLum < 0.4 ? 24 : avgLum > 0.7 ? 12 : 18;
|
||||
const suggestedSurfaceOpacity = mode === 'dark' ? 0.5 : 0.7;
|
||||
return { mode, accentHue, accent, suggestedBgOpacity, suggestedBlur, suggestedSurfaceOpacity };
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user