#!/usr/bin/env node /** * migrate-goals-to-okr.js — 把存量 OKR 数据从旧命名迁移到新命名 * * 依据: docs/todo/system-design/27-naming-conventions.md §六 * * 目录 data/goals/ → data/okr/ * data/companies/{cid}/goals/ → data/companies/{cid}/okr/ * 条目 {dir}/G-001/ → {dir}/O-001/ * 文件 G-001/goal.json → O-001/objective.json * ID G-001, G-001.1, G-001.1.1 → O-001, O-001.1, O-001.1.1 * (goal.json 的 id / nodes 的 key / children 数组 / _index.json / progress.json) * 日志 history.log 里的 G-xxx 引用一并改写(纯文本替换) * 任务 system/tasklog/*.json 的 goalId → objectiveId(值同样 G- → O-) * * 用法: * node scripts/migrate-goals-to-okr.js --dry-run # 只报告,不落盘 * node scripts/migrate-goals-to-okr.js # 实际执行 * * 幂等: 已经迁移过的(okr/ 已存在且 goals/ 不存在)会被跳过,可重复执行。 * 安全: 迁移前把每个待迁目录复制一份到 {dir}.bak-goals(已存在则不覆盖)。 */ const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const DATA_DIR = path.join(ROOT, 'data'); const TASKLOG_DIR = path.join(ROOT, 'system', 'tasklog'); const DRY = process.argv.includes('--dry-run'); const log = (...a) => console.log(DRY ? '[dry-run]' : '[migrate]', ...a); const stats = { dirs: 0, entries: 0, files: 0, tasklogs: 0, skipped: 0 }; /** G-001.1.1 → O-001.1.1;非 G- 开头的原样返回 */ function reId(id) { return typeof id === 'string' && /^G-/.test(id) ? id.replace(/^G-/, 'O-') : id; } /** 递归改写一个 objective 对象里所有 G- 形式的 ID */ function reObjective(obj) { const out = { ...obj, id: reId(obj.id) }; if (obj.nodes && typeof obj.nodes === 'object') { out.nodes = {}; for (const [nodeId, node] of Object.entries(obj.nodes)) { const n = { ...node }; if (Array.isArray(n.children)) n.children = n.children.map(reId); out.nodes[reId(nodeId)] = n; } } return out; } /** 目标目录是否只是 OkrService 自动建出来的空壳(无 O-xxx 条目,_index.json 空或缺失) */ function isEmptyOkrDir(dir) { const items = fs.readdirSync(dir, { withFileTypes: true }); const hasEntries = items.some(d => d.isDirectory() && /^O-/.test(d.name)); if (hasEntries) return false; const idx = path.join(dir, '_index.json'); if (!fs.existsSync(idx)) return true; const arr = readJson(idx); return Array.isArray(arr) && arr.length === 0; } function readJson(fp) { try { return JSON.parse(fs.readFileSync(fp, 'utf-8')); } catch { return null; } } function writeJson(fp, data) { if (DRY) return; fs.writeFileSync(fp, JSON.stringify(data, null, 2) + '\n', 'utf-8'); } /** 迁移一个 goals/ 目录 → okr/ */ function migrateDir(goalsDir, okrDir) { if (!fs.existsSync(goalsDir)) return; if (fs.existsSync(okrDir)) { // OkrService 构造函数会自动 mkdir okrDir,所以「目标已存在」不等于「已迁移过」—— // 只要服务被加载过一次就会留下一个空目录。区分两种情况: // 空壳(无 O-xxx 条目、_index.json 为空数组)→ 删掉它,继续迁移 // 有真实数据 → 跳过,避免覆盖 if (isEmptyOkrDir(okrDir)) { log(`目标目录是空壳(服务自动创建),移除后继续: ${path.relative(ROOT, okrDir)}`); if (!DRY) fs.rmSync(okrDir, { recursive: true, force: true }); } else { log(`跳过(目标已有数据): ${path.relative(ROOT, okrDir)}`); stats.skipped++; return; } } // 备份 const bak = `${goalsDir}.bak-goals`; if (!fs.existsSync(bak) && !DRY) { fs.cpSync(goalsDir, bak, { recursive: true }); } log(`目录: ${path.relative(ROOT, goalsDir)} → ${path.relative(ROOT, okrDir)}(备份 → ${path.basename(bak)})`); stats.dirs++; if (!DRY) fs.renameSync(goalsDir, okrDir); const workDir = DRY ? goalsDir : okrDir; // G-xxx 条目目录 → O-xxx const entries = fs.existsSync(workDir) ? fs.readdirSync(workDir, { withFileTypes: true }).filter(d => d.isDirectory() && d.name.startsWith('G-')) : []; for (const e of entries) { const oldEntry = path.join(workDir, e.name); const newEntry = path.join(workDir, reId(e.name)); log(` 条目: ${e.name} → ${reId(e.name)}`); stats.entries++; if (!DRY) fs.renameSync(oldEntry, newEntry); const dir = DRY ? oldEntry : newEntry; // goal.json → objective.json(含 ID 改写) const goalJson = path.join(dir, 'goal.json'); const objJson = path.join(dir, 'objective.json'); if (fs.existsSync(goalJson)) { const data = readJson(goalJson); if (data) { writeJson(objJson, reObjective(data)); if (!DRY) fs.unlinkSync(goalJson); log(` goal.json → objective.json`); stats.files++; } } else { log(` ⚠️ 无 goal.json(孤儿条目,仅迁移目录与日志)`); } // progress.json 的 goalId 字段 const progJson = path.join(dir, 'progress.json'); if (fs.existsSync(progJson)) { const p = readJson(progJson); if (p && p.goalId) { const { goalId, ...rest } = p; writeJson(progJson, { objectiveId: reId(goalId), ...rest }); stats.files++; } } // history.log 纯文本替换 const hist = path.join(dir, 'history.log'); if (fs.existsSync(hist) && !DRY) { const t = fs.readFileSync(hist, 'utf-8').replace(/\bG-(\d)/g, 'O-$1'); fs.writeFileSync(hist, t, 'utf-8'); } } // _index.json 的 id 字段 const idx = path.join(workDir, '_index.json'); if (fs.existsSync(idx)) { const arr = readJson(idx); if (Array.isArray(arr)) { writeJson(idx, arr.map(x => ({ ...x, id: reId(x.id) }))); log(` _index.json 已改写(${arr.length} 条)`); stats.files++; } } } /** system/tasklog/*.json 的 goalId → objectiveId */ function migrateTasklogs() { if (!fs.existsSync(TASKLOG_DIR)) { log('无 system/tasklog/,跳过'); return; } const files = fs.readdirSync(TASKLOG_DIR).filter(f => f.endsWith('.json')); for (const f of files) { const fp = path.join(TASKLOG_DIR, f); const meta = readJson(fp); if (!meta) continue; const oldObj = meta.objectiveId || meta.goalId; const needsIdRewrite = /^G-/.test(oldObj || '') || /^G-/.test(meta.nodeId || ''); const needsFieldAdd = oldObj && !('objectiveId' in meta); // 幂等:ID 已是 O- 前缀且 objectiveId 字段已存在 → 无需再写 if (!needsIdRewrite && !needsFieldAdd) continue; const next = { ...meta }; if (oldObj) { next.objectiveId = reId(oldObj); next.goalId = reId(oldObj); // 旧字段保留,供存量读取方兼容 } if (meta.nodeId) next.nodeId = reId(meta.nodeId); // 节点 ID 同样是 G- 形式 writeJson(fp, next); log(` tasklog: ${f} → objectiveId=${next.objectiveId || '-'}, nodeId=${next.nodeId || '-'}`); stats.tasklogs++; } } function main() { log(DRY ? '=== DRY RUN(不写盘)===' : '=== 开始迁移 ==='); // 全局 migrateDir(path.join(DATA_DIR, 'goals'), path.join(DATA_DIR, 'okr')); // 各公司 const companiesDir = path.join(DATA_DIR, 'companies'); if (fs.existsSync(companiesDir)) { for (const d of fs.readdirSync(companiesDir, { withFileTypes: true })) { if (!d.isDirectory()) continue; migrateDir( path.join(companiesDir, d.name, 'goals'), path.join(companiesDir, d.name, 'okr') ); } } migrateTasklogs(); console.log('\n' + (DRY ? '[dry-run] ' : '') + '汇总:', JSON.stringify(stats)); if (DRY) console.log('这是 dry-run,未做任何改动。去掉 --dry-run 实际执行。'); else console.log('完成。备份目录为 *.bak-goals,确认无误后可自行删除。'); } if (require.main === module) main(); module.exports = { reId, reObjective };