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:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user