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:
hunter-dev
2026-08-26 15:48:22 +08:00
parent 52474012c0
commit 55dedcbcf2
13 changed files with 206 additions and 47 deletions
@@ -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));
}
}
}
}