feat(M10/M3/M2): FR-M4/M6/X1/X2/B6/D1/D3 元数据/导出/弹层/迁移——EditorToolbar 加星标按钮(M4); DocMeta 加 createdAt+frontmatter 读写+DocInfoPanel 显示+创建时写入(M6); copyMd 失败 catch+错误提示(X1); @media print 干净排版+hljs配色(X2); SettingsPanel/CheatSheet Esc 关闭(B6); migration-service 旧版板块转换+init+switch 检测(D1/D3); meta-export E2E 3 项
This commit is contained in:
@@ -49,7 +49,11 @@ export class FileService {
|
||||
const rel = basePath ? joinRel(basePath, finalName) : finalName;
|
||||
const abs = this.abs(rel);
|
||||
if (isFolder) await fs.mkdir(abs, { recursive: true });
|
||||
else await writeFileAtomic(abs, '');
|
||||
else {
|
||||
// FR-M6:新建文档时写入 createdAt frontmatter
|
||||
const content = joinFrontmatter({ createdAt: Date.now() }, '');
|
||||
await writeFileAtomic(abs, content);
|
||||
}
|
||||
return { path: rel };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,68 @@
|
||||
// MigrationService — 旧版数据迁移判定与幂等保护(FR-D1/D2/D3)
|
||||
import { promises as fs } from 'node:fs';
|
||||
// MigrationService — 旧版数据迁移(FR-D1/D3)
|
||||
import nodePath from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { MD_EXT } from '@hunter/shared';
|
||||
import { joinFrontmatter } from '../safe/frontmatter-codec.js';
|
||||
|
||||
export interface MigrationResult { migrated: boolean; count: number; reason?: string; }
|
||||
export interface MigrationResult {
|
||||
migrated: boolean;
|
||||
count: number;
|
||||
errors: { path: string; reason: string }[];
|
||||
}
|
||||
|
||||
export class MigrationService {
|
||||
constructor(private userDataDir: string) {}
|
||||
// FR-D1:检测旧版板块三级结构数据(.hunter/boards/),转换为文件库结构
|
||||
async detectAndMigrate(root: string): Promise<MigrationResult> {
|
||||
const oldDataDir = nodePath.join(root, '.hunter', 'boards');
|
||||
const exists = await fs.access(oldDataDir).then(() => true).catch(() => false);
|
||||
if (!exists) return { migrated: false, count: 0, errors: [] };
|
||||
|
||||
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: '条件不满足(已迁移/无旧数据/新库非空)' };
|
||||
}
|
||||
const errors: { path: string; reason: string }[] = [];
|
||||
let count = 0;
|
||||
try {
|
||||
// TODO: 旧板块三级结构 → 文件夹嵌套;旧富文本 → Markdown;附件 → .attachments;标签与关联映射
|
||||
// 骨架阶段:仅写迁移标志,count=0
|
||||
await this.markMigrated();
|
||||
return { migrated: true, count: 0 };
|
||||
const boards = await fs.readdir(oldDataDir, { withFileTypes: true });
|
||||
for (const board of boards) {
|
||||
if (!board.isDirectory()) continue;
|
||||
const boardDir = nodePath.join(oldDataDir, board.name);
|
||||
try {
|
||||
const targetDir = nodePath.join(root, board.name);
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await this.convertDir(boardDir, targetDir);
|
||||
count++;
|
||||
} catch (e) {
|
||||
errors.push({ path: board.name, reason: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
if (errors.length === 0) {
|
||||
await fs.rename(oldDataDir, nodePath.join(root, '.hunter', 'boards.migrated'));
|
||||
}
|
||||
} catch (e) {
|
||||
// 失败不记标志(下次可重试,FR-D3)
|
||||
return { migrated: false, count: 0, reason: e instanceof Error ? e.message : String(e) };
|
||||
errors.push({ path: oldDataDir, reason: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
return { migrated: count > 0, count, errors };
|
||||
}
|
||||
|
||||
async markMigrated() {
|
||||
await fs.mkdir(nodePath.dirname(this.flagFile()), { recursive: true });
|
||||
await fs.writeFile(this.flagFile(), new Date().toISOString(), 'utf8');
|
||||
private async convertDir(srcAbs: string, destAbs: string): Promise<void> {
|
||||
const entries = await fs.readdir(srcAbs, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const srcPath = nodePath.join(srcAbs, e.name);
|
||||
if (e.isDirectory()) {
|
||||
const destPath = nodePath.join(destAbs, e.name);
|
||||
await fs.mkdir(destPath, { recursive: true });
|
||||
await this.convertDir(srcPath, destPath);
|
||||
} else if (e.isFile() && e.name.endsWith('.json')) {
|
||||
const raw = await fs.readFile(srcPath, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
const title = data.title ?? e.name.slice(0, -5);
|
||||
const body = data.content ?? '';
|
||||
const meta: Record<string, unknown> = { title, createdAt: data.createdAt ?? Date.now() };
|
||||
if (data.tags) meta.tags = data.tags;
|
||||
if (data.star) meta.star = true;
|
||||
const mdName = String(title).replace(/[^\w\u4e00-\u9fa5\-]/g, '_') + MD_EXT;
|
||||
await fs.writeFile(nodePath.join(destAbs, mdName), joinFrontmatter(meta as any, body), 'utf8');
|
||||
} else if (e.isFile() && e.name.endsWith(MD_EXT)) {
|
||||
await fs.copyFile(srcPath, nodePath.join(destAbs, e.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { VaultMeta } from '@hunter/shared';
|
||||
import { ConfigStore } from './config-store.js';
|
||||
import { FileService } from './file-service.js';
|
||||
import { SearchService } from './search-service.js';
|
||||
import { MigrationService } from './migration-service.js';
|
||||
|
||||
export class VaultService {
|
||||
fileService: FileService | null = null;
|
||||
@@ -27,6 +28,18 @@ export class VaultService {
|
||||
}
|
||||
this.fileService = new FileService(meta.currentRoot);
|
||||
this.searchService = new SearchService(meta.currentRoot, this.fileService);
|
||||
// FR-D1/D3:启动时检测并迁移旧版数据
|
||||
try {
|
||||
const migrator = new MigrationService();
|
||||
const result = await migrator.detectAndMigrate(meta.currentRoot);
|
||||
if (result.migrated) {
|
||||
console.log('[MIGRATION] migrated', result.count, 'boards, errors:', result.errors.length);
|
||||
if (result.errors.length > 0) console.warn('[MIGRATION] errors:', result.errors);
|
||||
}
|
||||
} catch (e) {
|
||||
// FR-D3:迁移失败不阻断应用
|
||||
console.warn('[MIGRATION] failed:', e);
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
@@ -58,9 +71,13 @@ export class VaultService {
|
||||
}
|
||||
|
||||
async switch(root: string): Promise<VaultMeta> {
|
||||
// 校验是存在的目录
|
||||
const stat = await fs.stat(root).catch(() => null);
|
||||
if (!stat || !stat.isDirectory()) throw new Error('目标路径不是有效目录: ' + root);
|
||||
// FR-D1:切库时也检测迁移
|
||||
try {
|
||||
const migrator = new MigrationService();
|
||||
await migrator.detectAndMigrate(root);
|
||||
} catch (e) { console.warn('[MIGRATION] switch failed:', e); }
|
||||
const meta = await this.config.setCurrentRoot(root);
|
||||
this.fileService = new FileService(root);
|
||||
this.searchService = new SearchService(root, this.fileService);
|
||||
|
||||
Reference in New Issue
Block a user