- scripts/archive-todo.cjs: 把看板已完成内容分离到归档(条目块 [x] 自动移出;历史/导航段走标题白名单整段移出,不靠启发式猜--本板存在大量无勾选的行内待办,启发式会误移) - 安全闸: 未完成项零丢失断言(失败即中止不写);默认 dry-run;--list 从 UTF-8 文件读白名单(规避 Windows CLI 中文编码);容器标题(下挂更深子节)不当作空标题删 - 本次移出 6 段(统一规划 2026-06-26 全景/交接状态 2026-06-14/编排推进总览 2026-06-18)+ 909 条目;主板 847→736 行 - 核对: 未完成项 109 项零丢失,主板 [x] 清零,归档收 909 及其验收块
136 lines
6.1 KiB
JavaScript
136 lines
6.1 KiB
JavaScript
// 看板归档器:把 docs/todo.md 中「已完成」内容分离到 docs/07-项目管理/todo归档/<date>.md。
|
||
//
|
||
// 移动规则(纯行搬运,零措辞改动):
|
||
// ① 条目块为 [x] → 自动移出(其后续非条目行为同块延续,含缩进证据/引用块);
|
||
// ② **白名单整段移出**:命令行给出的标题(用引号原样传入) → 该段连同其子段(级别更深的后续段)
|
||
// 一并移出。历史/导航/已完成段走此路——**不靠启发式猜「已完成」**(本板存在大量无勾选的
|
||
// 行内待办,启发式会误移)。
|
||
// ③ 移出后残留的空标题(段内仅空行)自动删除,防悬空标题。
|
||
//
|
||
// 安全闸:未完成项([ ]/[~]/[→]/[!])零丢失断言失败即中止不写;默认 dry-run。
|
||
//
|
||
// 用法:
|
||
// node scripts/archive-todo.cjs # dry-run
|
||
// node scripts/archive-todo.cjs "## 🎯 统一规划(2026-06-26 全景)" # dry-run + 试移该段
|
||
// node scripts/archive-todo.cjs --apply "## 交接状态(2026-06-14)" # 实际写入
|
||
// node scripts/archive-todo.cjs --apply --date 2026-10-11 ...
|
||
const fs = require('fs');
|
||
|
||
const argv = process.argv.slice(2);
|
||
const APPLY = argv.includes('--apply');
|
||
const di = argv.indexOf('--date');
|
||
const DATE = di >= 0 ? argv[di + 1] : new Date().toISOString().slice(0, 10);
|
||
const li = argv.indexOf('--list');
|
||
// 白名单来源:① 命令行位置参数(注意 Windows Git Bash 下中文会被编码破坏,慎用)
|
||
// ② --list <file>:UTF-8 文件,每行一个精确标题(推荐,避免编码问题)
|
||
const WHITELIST =
|
||
li >= 0
|
||
? fs.readFileSync(argv[li + 1], 'utf8').split('\n').map((s) => s.trim()).filter(Boolean)
|
||
: argv.filter((a, i) => !a.startsWith('--') && i !== di + 1 && i !== li + 1);
|
||
|
||
const SRC = 'docs/todo.md';
|
||
const ARC = `docs/07-项目管理/todo归档/${DATE}.md`;
|
||
const UNDONE = /^\s*- \[[ ~→!]\]|^\d+\. \[[ ~→!]\]/;
|
||
const DONE = /^\s*- \[x\]|^\d+\. \[x\]/i;
|
||
const BLOCK = /^(\s*- \[|\d+\. \[)/;
|
||
const HEAD = /^#{1,3} /;
|
||
const level = (h) => (h.match(/^#+/) || [''])[0].length;
|
||
|
||
// 行尾归一:工作区可能是 CRLF,按 \r?\n 切(否则标题比对带 \r 恒不等)。写回用 LF,git 提交时归一。
|
||
const lines = fs.readFileSync(SRC, 'utf8').split(/\r?\n/);
|
||
|
||
// 打标:``` 围栏内一律 text(防 mermaid/示例里的 ## 误导分段)
|
||
const kinds = [];
|
||
let inFence = false;
|
||
for (const ln of lines) {
|
||
if (ln.trim().startsWith('```')) { kinds.push('text'); inFence = !inFence; continue; }
|
||
kinds.push(inFence ? 'text' : HEAD.test(ln) ? 'head' : BLOCK.test(ln) ? 'block' : 'text');
|
||
}
|
||
|
||
// 分段(首个 head 前为 preamble,永留主板)
|
||
const segs = [];
|
||
let cur = { head: null, headLevel: 0, items: [] };
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (kinds[i] === 'head') { segs.push(cur); cur = { head: lines[i], headLevel: level(lines[i]), items: [] }; }
|
||
else cur.items.push(lines[i]);
|
||
}
|
||
segs.push(cur);
|
||
const preamble = segs[0].head === null ? segs.shift() : null;
|
||
|
||
// 块归并(块 = 条目起始行 + 其后非条目行)
|
||
function blocksOf(items) {
|
||
const out = [];
|
||
let curB = null;
|
||
for (const ln of items) {
|
||
if (BLOCK.test(ln)) {
|
||
if (curB) out.push(curB);
|
||
curB = { status: DONE.test(ln) ? 'done' : UNDONE.test(ln) ? 'undone' : 'other', lines: [ln] };
|
||
} else if (curB) curB.lines.push(ln);
|
||
else out.push({ status: 'text', lines: [ln] });
|
||
}
|
||
if (curB) out.push(curB);
|
||
return out;
|
||
}
|
||
|
||
const moved = [];
|
||
const kept = [];
|
||
if (preamble) kept.push(...preamble.items);
|
||
|
||
let treeLevel = null; // 白名单段的级别:更深的后续段随其移出
|
||
let movedSegs = 0, movedBlocks = 0;
|
||
for (const seg of segs) {
|
||
const whitelisted = seg.head !== null && WHITELIST.includes(seg.head);
|
||
const inTree = treeLevel !== null && seg.head !== null && seg.headLevel > treeLevel;
|
||
if (!seg.head) { /* 不可能:非 preamble 段必有 head */ }
|
||
if (whitelisted || inTree) {
|
||
if (whitelisted) treeLevel = seg.headLevel;
|
||
movedSegs++;
|
||
if (seg.head) moved.push(seg.head);
|
||
for (const b of blocksOf(seg.items)) moved.push(...b.lines);
|
||
continue;
|
||
}
|
||
treeLevel = null;
|
||
if (seg.head) kept.push(seg.head);
|
||
for (const b of blocksOf(seg.items)) {
|
||
if (b.status === 'done') { movedBlocks++; moved.push(...b.lines); }
|
||
else kept.push(...b.lines);
|
||
}
|
||
}
|
||
|
||
// 删残留空标题:仅当「段内只剩空行」且「下一个标题级别 ≤ 当前」才删——
|
||
// 若下一个标题更深,说明它是**容器标题**(如 `## 待办` 下挂 `### 子节`),必须保留。
|
||
const cleaned = [];
|
||
for (let i = 0; i < kept.length; i++) {
|
||
if (HEAD.test(kept[i])) {
|
||
let j = i + 1;
|
||
while (j < kept.length && kept[j].trim() === '') j++;
|
||
const nextIsDeeper = j < kept.length && HEAD.test(kept[j]) && level(kept[j]) > level(kept[i]);
|
||
if (j >= kept.length || (HEAD.test(kept[j]) && !nextIsDeeper)) continue;
|
||
}
|
||
cleaned.push(kept[i]);
|
||
}
|
||
|
||
// 零丢失断言
|
||
const origUndone = lines.filter((l) => UNDONE.test(l));
|
||
const newUndone = new Set(cleaned.filter((l) => UNDONE.test(l)));
|
||
const lost = origUndone.filter((l) => !newUndone.has(l));
|
||
|
||
console.log(`[archive-todo] ${APPLY ? 'APPLY' : 'DRY-RUN'} date=${DATE} whitelist=${WHITELIST.length}`);
|
||
console.log(` 整段移出 ${movedSegs} 段,条目块移出 ${movedBlocks} 个,移出 ${moved.length} 行,主板 ${cleaned.length} 行(原 ${lines.length})`);
|
||
console.log(` 未完成项 原 ${origUndone.length} → 主板 ${newUndone.size},丢失 ${lost.length}`);
|
||
if (lost.length) {
|
||
console.error('✗ 零丢失断言失败,不写入:');
|
||
for (const l of lost.slice(0, 10)) console.error(' ' + l.slice(0, 100));
|
||
process.exit(1);
|
||
}
|
||
console.log(' 移出段标题:');
|
||
for (const l of moved.filter((x) => HEAD.test(x))) console.log(' - ' + l.slice(0, 90));
|
||
|
||
if (!APPLY) { console.log('(dry-run,加 --apply 写入)'); process.exit(0); }
|
||
if (moved.length === 0) { console.log('无内容可移,跳过写入'); process.exit(0); }
|
||
|
||
let arc = fs.existsSync(ARC) ? fs.readFileSync(ARC, 'utf8').trimEnd() : `# todo 归档(${DATE})`;
|
||
fs.writeFileSync(ARC, `${arc}\n\n---\n\n## ${DATE} 分拆(已完成内容再分离)\n\n${moved.join('\n')}\n`, 'utf8');
|
||
fs.writeFileSync(SRC, cleaned.join('\n'), 'utf8');
|
||
console.log(`[archive-todo] 已写入 ${SRC} 与 ${ARC}`);
|