/ clis / mubu / notes.js
notes.js
  1  import { cli, Strategy } from '@jackwener/opencli/registry';
  2  import { ArgumentError } from '@jackwener/opencli/errors';
  3  import { mubuPost, nodesToMarkdown, nodesToText, htmlToText } from './utils.js';
  4  
  5  // ── 日期工具 ──────────────────────────────────────────────
  6  
  7  function localToday() {
  8    const d = new Date();
  9    return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
 10  }
 11  
 12  function lastDayOfMonth(year, month) {
 13    return new Date(year, month, 0).getDate();
 14  }
 15  
 16  function validateYear(year, label = '年份') {
 17    if (!Number.isInteger(year) || year < 1) {
 18      throw new ArgumentError(`${label} 非法:${year},应为正整数`);
 19    }
 20  }
 21  
 22  function validateMonth(month) {
 23    if (!Number.isInteger(month) || month < 1 || month > 12) {
 24      throw new ArgumentError(`月份非法:${month},应为 1-12`);
 25    }
 26  }
 27  
 28  function validateDay(year, month, day) {
 29    const maxDay = lastDayOfMonth(year, month);
 30    if (!Number.isInteger(day) || day < 1 || day > maxDay) {
 31      throw new ArgumentError(`日期非法:${year}-${month}-${day}(${year} 年 ${month} 月共 ${maxDay} 天)`);
 32    }
 33  }
 34  
 35  function parseDate(s) {
 36    const parts = s.split('-').map(Number);
 37    if (parts.length !== 3 || parts.some(isNaN)) {
 38      throw new ArgumentError(`日期格式错误:${s},应为 YYYY-MM-DD`);
 39    }
 40    const [year, month, day] = parts;
 41    validateYear(year);
 42    validateMonth(month);
 43    validateDay(year, month, day);
 44    return { year, month, day };
 45  }
 46  
 47  function parseMonth(s) {
 48    const parts = s.split('-').map(Number);
 49    if (parts.length !== 2 || parts.some(isNaN)) {
 50      throw new ArgumentError(`月份格式错误:${s},应为 YYYY-MM`);
 51    }
 52    const [year, month] = parts;
 53    validateYear(year);
 54    validateMonth(month);
 55    return { year, month };
 56  }
 57  
 58  function dateToKey(d) {
 59    return `${d.year}-${String(d.month).padStart(2, '0')}-${String(d.day).padStart(2, '0')}`;
 60  }
 61  
 62  /** 将各种时间参数统一解析为 {start, end} */
 63  function resolveRange(kwargs) {
 64    const dateStr = kwargs.date;
 65    const monthStr = kwargs.month;
 66    const yearArg = kwargs.year;
 67    const fromStr = kwargs.from;
 68    const toStr = kwargs.to;
 69  
 70    // --from / --to 优先级最高
 71    if (fromStr || toStr) {
 72      if (!fromStr) throw new ArgumentError('使用 --to 时必须同时指定 --from');
 73      const start = parseDate(fromStr);
 74      const end = toStr ? parseDate(toStr) : localToday();
 75      if (dateToKey(start) > dateToKey(end)) throw new ArgumentError('--from 不能晚于 --to');
 76      return { start, end };
 77    }
 78  
 79    if (yearArg !== undefined && yearArg !== null) {
 80      validateYear(yearArg, '--year');
 81      return {
 82        start: { year: yearArg, month: 1, day: 1 },
 83        end: { year: yearArg, month: 12, day: 31 },
 84      };
 85    }
 86  
 87    if (monthStr) {
 88      const { year, month } = parseMonth(monthStr);
 89      return {
 90        start: { year, month, day: 1 },
 91        end: { year, month, day: lastDayOfMonth(year, month) },
 92      };
 93    }
 94  
 95    if (dateStr) {
 96      const d = parseDate(dateStr);
 97      return { start: d, end: d };
 98    }
 99  
100    // 默认:今天
101    const today = localToday();
102    return { start: today, end: today };
103  }
104  
105  // ── API 工具 ──────────────────────────────────────────────
106  
107  async function getYearDocId(page, year) {
108    const raw = await page.evaluate(`localStorage.getItem('daily_notes_doc_list')`);
109    if (!raw) return null;
110    const list = JSON.parse(raw);
111    return list.find((d) => d.name === `${year}年`)?.id ?? null;
112  }
113  
114  async function getYearNodes(page, docId) {
115    const data = await mubuPost(page, '/document/edit/get', { docId });
116    const def = JSON.parse(data.definition);
117    return def.nodes ?? [];
118  }
119  
120  /** 加载某年的所有 day 节点,返回带 dateKey 的列表 */
121  async function loadYearEntries(page, year) {
122    const docId = await getYearDocId(page, year);
123    if (!docId) return [];
124  
125    const yearNodes = await getYearNodes(page, docId);
126    const entries = [];
127  
128    for (const monthNode of yearNodes) {
129      const monthNum = parseInt(htmlToText(monthNode.text), 10);
130      if (!monthNode.children?.length) continue;
131  
132      for (const dayNode of monthNode.children) {
133        const plain = htmlToText(dayNode.text).replace(/\s+/g, ' ').trim();
134        const compact = plain.replace(/\s/g, '');
135        const match = compact.match(/^(\d+)月(\d+)日/);
136        if (!match) continue;
137        const m = parseInt(match[1], 10);
138        const d = parseInt(match[2], 10);
139        if (m !== monthNum) continue;
140  
141        const dateKey = dateToKey({ year, month: m, day: d });
142        entries.push({ dateKey, label: plain, node: dayNode });
143      }
144    }
145  
146    return entries;
147  }
148  
149  /** 收集 [start, end] 范围内涉及的所有年份 */
150  function yearsInRange(start, end) {
151    const years = [];
152    for (let y = start.year; y <= end.year; y++) years.push(y);
153    return years;
154  }
155  
156  // ── 命令 ──────────────────────────────────────────────────
157  
158  cli({
159    site: 'mubu',
160    name: 'notes',
161    description: '读取幕布速记(默认今天)。支持 --date/--month/--year/--from/--to 指定时间范围,--list 为概览模式(日期+条数)。',
162    domain: 'mubu.com',
163    strategy: Strategy.COOKIE,
164    args: [
165      {
166        name: 'list',
167        type: 'bool',
168        default: false,
169        help: '概览模式:只输出日期和条数,不含速记内容。可与任意时间范围参数组合。',
170      },
171      {
172        name: 'date',
173        help: '单日,格式 YYYY-MM-DD。不指定时间范围则默认今天(系统本地时间)。',
174      },
175      {
176        name: 'month',
177        help: '整月,格式 YYYY-MM。',
178      },
179      {
180        name: 'year',
181        type: 'int',
182        help: '整年,格式 YYYY(整数)。',
183      },
184      {
185        name: 'from',
186        help: '范围起始日,格式 YYYY-MM-DD。须与 --to 同时使用。',
187      },
188      {
189        name: 'to',
190        help: '范围截止日,格式 YYYY-MM-DD。须与 --from 同时使用。',
191      },
192      {
193        name: 'output',
194        default: 'md',
195        help: '输出格式:md(默认,Markdown)或 text(纯文本)',
196      },
197    ],
198    columns: ['date', 'content'],
199    func: async (page, kwargs) => {
200      const isList = kwargs.list;
201      const format = kwargs.output;
202      if (format !== 'md' && format !== 'text') {
203        throw new ArgumentError(`--output 只接受 md 或 text,收到:${format}`);
204      }
205  
206      await page.goto('https://mubu.com/app');
207  
208      const { start, end } = resolveRange(kwargs);
209      const startKey = dateToKey(start);
210      const endKey = dateToKey(end);
211  
212      // 并行加载所有涉及年份的 day 节点,按范围过滤
213      const yearResults = await Promise.all(
214        yearsInRange(start, end).map((year) => loadYearEntries(page, year)),
215      );
216      const allEntries = yearResults
217        .flat()
218        .filter((e) => e.dateKey >= startKey && e.dateKey <= endKey);
219  
220      if (allEntries.length === 0) {
221        const label = startKey === endKey ? startKey : `${startKey} ~ ${endKey}`;
222        return [{ date: label, content: '该时间段暂无速记' }];
223      }
224  
225      // 概览模式
226      if (isList) {
227        return allEntries.map((e) => ({
228          date: e.label,
229          content: `${e.node.children?.length ?? 0} 条记录`,
230        }));
231      }
232  
233      // 内容模式
234      const render = (children) =>
235        format === 'text' ? nodesToText(children) : nodesToMarkdown(children);
236  
237      return allEntries
238        .filter((e) => e.node.children?.length)
239        .map((e) => ({
240          date: e.label,
241          content: render(e.node.children ?? []) || '(空)',
242        }));
243    },
244  });