// 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; errors: { path: string; reason: string }[]; } export class MigrationService { // FR-D1:检测旧版板块三级结构数据(.hunter/boards/),转换为文件库结构 async detectAndMigrate(root: string): Promise { 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: [] }; const errors: { path: string; reason: string }[] = []; let count = 0; try { 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) { errors.push({ path: oldDataDir, reason: e instanceof Error ? e.message : String(e) }); } return { migrated: count > 0, count, errors }; } private async convertDir(srcAbs: string, destAbs: string): Promise { 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 = { 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)); } } } }