migrate-goals-to-okr.js 7.93 KB
#!/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 };