#!/usr/bin/env node /** * todo/todo.mjs — 项目 TODO 管理 CLI(零依赖,Node.js >= 18) * * 子命令: * add --title "..." [--level high|mid|low] [--tags a,b] [--desc "..."] * done [--version vX.Y.Z] * reopen * rm * list * render */ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { execSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const __dir = dirname(fileURLToPath(import.meta.url)); const DB = resolve(__dir, 'todo.json'); const HTML = resolve(__dir, 'todo.html'); // ─── 数据读写 ──────────────────────────────────────────────────────────────── function load() { if (!existsSync(DB)) { return { meta: { title: '酒库管理系统 — 项目 TODO', updated_at: now() }, seq: 0, items: [] }; } return JSON.parse(readFileSync(DB, 'utf8')); } function save(db) { db.meta.updated_at = now(); writeFileSync(DB, JSON.stringify(db, null, 2) + '\n', 'utf8'); } function now() { return new Date().toISOString(); } function fmtDate(iso) { if (!iso) return ''; const d = new Date(iso); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function gitTag() { try { return execSync('git describe --tags --abbrev=0', { encoding: 'utf8', stdio: ['ignore','pipe','ignore'] }).trim(); } catch { return null; } } // ─── 终端 summary ──────────────────────────────────────────────────────────── const LEVEL_LABEL = { high: '高', mid: '中', low: '低' }; const LEVEL_ORDER = { high: 0, mid: 1, low: 2 }; function printSummary(db) { const pending = db.items.filter(i => !i.done) .sort((a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id); const done = db.items.filter(i => i.done) .sort((a,b) => (b.completed_at||'') > (a.completed_at||'') ? 1 : -1); console.log(`\nTODO 共 ${db.items.length} · 未完成 ${pending.length} · 已完成 ${done.length}`); if (pending.length) { console.log('未完成(按重要度):'); for (const i of pending) { const tags = i.tags && i.tags.length ? i.tags.join('/') : ''; const lvl = LEVEL_LABEL[i.level] || i.level; console.log(` [${lvl}] #${i.id} ${i.title}${tags ? ' ' + tags : ''}`); } } if (done.length) { console.log('已完成(最近 5 条):'); for (const i of done.slice(0, 5)) { const ver = i.version ? `(${i.version})` : ''; console.log(` ✅ #${i.id} ${i.title}${ver}`); } } console.log(''); } // ─── HTML 渲染 ─────────────────────────────────────────────────────────────── function esc(s) { return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } /** 把描述里的 `code` 渲染成 */ function renderDesc(s) { if (!s) return ''; return esc(s).replace(/`([^`]+)`/g, '$1'); } function levelClass(level) { return { high: 't-block', mid: 't-high', low: 't-low' }[level] || 't-low'; } function levelText(level) { return { high: '高优 · 紧急', mid: '重要', low: '一般 / 优化' }[level] || level; } function renderItems(items) { if (!items.length) return '

暂无条目

'; return items.map(i => { const tags = (i.tags||[]).map(t => `${esc(t)}`).join(' '); const desc = i.desc ? `
${renderDesc(i.desc)}
` : ''; const meta = `
🕐 ${fmtDate(i.created_at)} ${i.done ? `✅ 完成 ${fmtDate(i.completed_at)}${i.version ? ' · '+esc(i.version)+'' : ''}` : ''}
`; const lvlCls = levelClass(i.level); return `
  • ${esc(i.title)} ${levelText(i.level)}
    ${desc}
  • `; }).join('\n'); } function buildHtml(db) { const allTags = [...new Set(db.items.flatMap(i => i.tags||[]))].sort(); const pending = db.items.filter(i => !i.done) .sort((a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id); const done = db.items.filter(i => i.done) .sort((a,b) => (b.completed_at||'') > (a.completed_at||'') ? 1 : -1); const tagChips = allTags.map(t => `` ).join(''); return ` ${esc(db.meta.title)}

    ${esc(db.meta.title)}

    生成于 ${fmtDate(db.meta.updated_at)} · 真相源 todo/todo.json
    ${db.items.length}全部
    ${pending.length}未完成
    ${done.length}已完成
    重要度
    状态
    ${allTags.length ? `
    平台 / 标签 ${tagChips}
    ` : ''}
    📋 未完成(${pending.length})
      ${renderItems(pending)}
    ✅ 已完成(${done.length})
    `; } // ─── CLI 解析 ──────────────────────────────────────────────────────────────── function parseArgs(argv) { const args = {}; let pos = []; for (let i = 0; i < argv.length; i++) { if (argv[i].startsWith('--')) { const key = argv[i].slice(2); const val = argv[i+1] && !argv[i+1].startsWith('--') ? argv[++i] : true; args[key] = val; } else { pos.push(argv[i]); } } args._ = pos; return args; } function findItem(db, id) { const n = parseInt(id, 10); const item = db.items.find(i => i.id === n); if (!item) { console.error(`错误:找不到 id=${n} 的条目`); process.exit(1); } return item; } // ─── 子命令 ───────────────────────────────────────────────────────────────── function cmdAdd(db, args) { const title = args.title || (args._.join(' ') || '').trim(); if (!title) { console.error('错误:--title 不能为空'); process.exit(1); } const level = ['high','mid','low'].includes(args.level) ? args.level : 'mid'; const tags = args.tags ? args.tags.split(',').map(t => t.trim()).filter(Boolean) : []; const desc = args.desc || null; db.seq++; const item = { id: db.seq, title, desc, level, tags, created_at: now(), done: false, completed_at: null, version: null, }; db.items.push(item); save(db); writeFileSync(HTML, buildHtml(db), 'utf8'); console.log(`✅ 已添加 #${item.id} [${levelText(level)}] ${title}${tags.length ? ' ' + tags.join('/') : ''}`); printSummary(db); } function cmdDone(db, args) { const id = args._[0]; const item = findItem(db, id); let version = args.version || null; if (!version) { version = gitTag(); if (!version) console.warn('⚠️ 无法从 git 获取版本号,version 将为空'); } item.done = true; item.completed_at = now(); item.version = version; save(db); writeFileSync(HTML, buildHtml(db), 'utf8'); console.log(`✅ #${item.id}「${item.title}」已标记完成${version ? ',记入版本 ' + version : ''}`); printSummary(db); } function cmdReopen(db, args) { const id = args._[0]; const item = findItem(db, id); item.done = false; item.completed_at = null; item.version = null; save(db); writeFileSync(HTML, buildHtml(db), 'utf8'); console.log(`🔄 #${item.id}「${item.title}」已重新开启`); printSummary(db); } function cmdRm(db, args) { const id = parseInt(args._[0], 10); const idx = db.items.findIndex(i => i.id === id); if (idx < 0) { console.error(`错误:找不到 id=${id}`); process.exit(1); } const [removed] = db.items.splice(idx, 1); save(db); writeFileSync(HTML, buildHtml(db), 'utf8'); console.log(`🗑️ #${removed.id}「${removed.title}」已删除`); printSummary(db); } function cmdList(db) { // 确保 HTML 最新 writeFileSync(HTML, buildHtml(db), 'utf8'); printSummary(db); console.log(`📄 HTML:todo/todo.html`); } function cmdRender(db) { writeFileSync(HTML, buildHtml(db), 'utf8'); console.log('🖨️ todo/todo.html 已重新渲染'); } // ─── 入口 ──────────────────────────────────────────────────────────────────── const [,, subcmd, ...rest] = process.argv; const db = load(); const args = parseArgs(rest); switch (subcmd) { case 'add': cmdAdd(db, args); break; case 'done': cmdDone(db, args); break; case 'reopen': cmdReopen(db, args); break; case 'rm': cmdRm(db, args); break; case 'list': case undefined: case '': cmdList(db); break; case 'render': cmdRender(db); break; default: console.error(`未知子命令:${subcmd}\n用法:node todo.mjs add|done|reopen|rm|list|render`); process.exit(1); }