migrate-goals-to-okr.js
7.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
#!/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 };