/ clis / mubu / utils.js
utils.js
  1  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
  2  
  3  export const API_BASE = 'https://api2.mubu.com/v3/api';
  4  const MUBU_DOMAIN = 'mubu.com';
  5  const AUTH_HINT = 'Mubu requires a logged-in browser session at mubu.com';
  6  
  7  function isAuthFailure(code, message) {
  8    if (code === 401 || code === 403) return true;
  9    if (!message) return false;
 10    return /not logged in|login required|unauthorized|未登录|请先登录|需要登录|login expired/i.test(message);
 11  }
 12  
 13  /**
 14   * 在浏览器页面上下文里用 XHR 发 POST 请求(参考 zsxq 适配器模式)。
 15   * mubu app 自身也是这个机制:从 localStorage 读 Jwt-Token,通过同名 header 发到 api2.mubu.com。
 16   * 不经过 Node.js 进程发网络请求,避免 CORS 问题和 extension fetch 拦截。
 17   */
 18  export async function mubuPost(page, path, body) {
 19    const url = `${API_BASE}${path}`;
 20  
 21    const result = await page.evaluate(`
 22      (async () => {
 23        const token = localStorage.getItem('Jwt-Token');
 24        if (!token) return { ok: false, status: 0, data: null, error: 'no token' };
 25        return await new Promise((resolve) => {
 26          const xhr = new XMLHttpRequest();
 27          xhr.open('POST', ${JSON.stringify(url)}, true);
 28          xhr.setRequestHeader('Content-Type', 'application/json');
 29          xhr.setRequestHeader('Jwt-Token', token);
 30          xhr.onload = () => {
 31            let data = null;
 32            try { data = JSON.parse(xhr.responseText); } catch {}
 33            resolve({ ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, data });
 34          };
 35          xhr.onerror = () => resolve({ ok: false, status: 0, data: null, error: 'network error' });
 36          xhr.send(${JSON.stringify(JSON.stringify(body))});
 37        });
 38      })()
 39    `);
 40  
 41    if (!result || result.error === 'no token') {
 42      throw new AuthRequiredError(MUBU_DOMAIN, AUTH_HINT);
 43    }
 44    if (!result.ok || !result.data) {
 45      throw new CommandExecutionError(`mubu: ${path}: HTTP ${result.status} ${result.error ?? ''}`);
 46    }
 47  
 48    const { data } = result;
 49    if (data.code !== 0) {
 50      if (isAuthFailure(data.code, data.message)) {
 51        throw new AuthRequiredError(MUBU_DOMAIN, AUTH_HINT);
 52      }
 53      throw new CommandExecutionError(`mubu: ${path}: code=${data.code} ${data.message ?? ''}`);
 54    }
 55  
 56    return data.data;
 57  }
 58  
 59  export function formatDate(ts) {
 60    if (!ts) return '';
 61    const d = new Date(ts);
 62    const pad = (n) => String(n).padStart(2, '0');
 63    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
 64  }
 65  
 66  const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
 67  
 68  function decodeHtmlEntities(s) {
 69    return s
 70      .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
 71      .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
 72      .replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_, n) => NAMED_ENTITIES[n]);
 73  }
 74  
 75  /** 解析幕布 HTML 表格为行列二维数组(保留内部 HTML) */
 76  function parseTableRows(tableHtml) {
 77    const rows = [];
 78    const rowMatches = tableHtml.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi) ?? [];
 79    for (const row of rowMatches) {
 80      const cells = [];
 81      const cellMatches = row.match(/<(?:td|th)[^>]*>[\s\S]*?<\/(?:td|th)>/gi) ?? [];
 82      for (const cell of cellMatches) {
 83        // 只剥离最外层的 <td> 或 <th>,保留内部的加粗、链接和 <br>
 84        let innerHtml = cell.replace(/^<(?:td|th)[^>]*>|<\/(?:td|th)>$/gi, '');
 85        cells.push(innerHtml.trim());
 86      }
 87      rows.push(cells);
 88    }
 89    return rows;
 90  }
 91  
 92  /** 将幕布 HTML text 转为纯文本 */
 93  export function htmlToText(html) {
 94    let text = html;
 95    // 表格 → 纯文本(tab 分隔);用 </table>\s*</div> 作结束锚,跳过 th/td 内部嵌套 div
 96    text = text.replace(/<div class="table-container">[\s\S]*?<\/table>\s*<\/div>/g, (m) => {
 97      return parseTableRows(m).map((r) => r.map(c => {
 98        // 纯文本环境:<br> 换空格,清空所有标签
 99        let plainCell = c.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '');
100        return decodeHtmlEntities(plainCell).trim();
101      }).join('\t')).join('\n');
102    });
103    text = text
104      .replace(/<br\s*\/?>/gi, '\n')
105      .replace(/<[^>]+>/g, '');
106    return decodeHtmlEntities(text).trim();
107  }
108  
109  /** 将幕布 HTML 表格转为 Markdown 表格 */
110  function tableToMarkdown(tableHtml) {
111    const rows = parseTableRows(tableHtml);
112    if (rows.length === 0) return '';
113    
114    const processRow = (row) => row.map(cellHtml => {
115      // 把表格内的 <br> 换成占位符,防止稍后被全局替换为 \n 导致表格断裂
116      return cellHtml.replace(/<br\s*\/?>/gi, '[[BR]]');
117    }).join(' | ');
118  
119    const lines = [`| ${processRow(rows[0])} |`, `| ${rows[0].map(() => '---').join(' | ')} |`];
120    for (let i = 1; i < rows.length; i++) {
121      lines.push(`| ${processRow(rows[i])} |`);
122    }
123    return lines.join('\n');
124  }
125  
126  /** 将幕布 HTML text 转为 Markdown inline 标记 */
127  export function htmlToMarkdown(html) {
128    let md = html;
129    
130    // 1. 表格 → Markdown 表格
131    md = md.replace(/<div class="table-container">[\s\S]*?<\/table>\s*<\/div>/g, (m) => tableToMarkdown(m));
132    
133    // 2. <br> → 换行
134    md = md.replace(/<br\s*\/?>/gi, '\n');
135    
136    // 3. 统一处理样式标签,支持多 class 组合
137    md = md.replace(/<span class="([^"]+)"[^>]*>([\s\S]*?)<\/span>/gi, (match, classes, inner) => {
138      // 允许正常处理超链接内部的 bold 和 italic 样式
139      if (classes.includes('node-mention')) {
140        return match;
141      }
142      let res = inner;
143      if (/\bbold\b/.test(classes)) res = `**${res}**`;
144      if (/\bitalic\b/.test(classes)) res = `*${res}*`;
145      if (/\bstrikethrough\b/.test(classes)) res = `~~${res}~~`;
146      if (/\bunderline\b/.test(classes)) res = `\uFFFEU_OPEN\uFFFE${res}\uFFFEU_CLOSE\uFFFE`;
147      return res;
148    });
149  
150    // 4. node-mention(主题链接 → Markdown 链接,支持组合 class 并继承自身样式)
151    md = md.replace(
152      /<span([^>]*\bclass="[^"]*\bnode-mention\b[^"]*"[^>]*)>([\s\S]*?)<\/span>/gi,
153      (match, attrs, inner) => {
154        const docMatch = attrs.match(/\bdata-doc="([^"]+)"/i);
155        const docId = docMatch ? docMatch[1] : '';
156        if (!docId) return match;
157        
158        const classMatch = attrs.match(/\bclass="([^"]+)"/i);
159        const classes = classMatch ? classMatch[1] : '';
160        
161        let res = inner.replace(/<[^>]+>/g, '').trim();
162        
163        // 继承标签自身的样式
164        if (/\bbold\b/.test(classes)) res = `**${res}**`;
165        if (/\bitalic\b/.test(classes)) res = `*${res}*`;
166        if (/\bstrikethrough\b/.test(classes)) res = `~~${res}~~`;
167        if (/\bunderline\b/.test(classes)) res = `\uFFFEU_OPEN\uFFFE${res}\uFFFEU_CLOSE\uFFFE`;
168        
169        return `[${res}](https://mubu.com/app/edit/${docId})`;
170      }
171    );
172    
173    // 5. links (外部链接 → Markdown 链接,继承 a 标签自身样式)
174    md = md.replace(/<a([^>]*)>([\s\S]*?)<\/a>/gi, (match, attrs, inner) => {
175      const hrefMatch = attrs.match(/\bhref="([^"]+)"/i);
176      if (!hrefMatch) return match;
177      const href = hrefMatch[1];
178      
179      const classMatch = attrs.match(/\bclass="([^"]+)"/i);
180      const classes = classMatch ? classMatch[1] : '';
181      
182      let res = inner;
183      
184      // 继承 a 标签自身的样式
185      if (/\bbold\b/.test(classes)) res = `**${res}**`;
186      if (/\bitalic\b/.test(classes)) res = `*${res}*`;
187      if (/\bstrikethrough\b/.test(classes)) res = `~~${res}~~`;
188      if (/\bunderline\b/.test(classes)) res = `\uFFFEU_OPEN\uFFFE${res}\uFFFEU_CLOSE\uFFFE`;
189      
190      return `[${res}](${href})`;
191    });
192    
193    // 6. 普通 span
194    md = md.replace(/<span[^>]*>([\s\S]*?)<\/span>/gi, '$1');
195    
196    // 7. 清理其余标签
197    md = md.replace(/<[^>]+>/g, '');
198    
199    // 8. HTML 实体解码
200    md = decodeHtmlEntities(md);
201    
202    // 9. 还原 underline 占位符
203    md = md.replace(/\uFFFEU_OPEN\uFFFE/g, '<u>').replace(/\uFFFEU_CLOSE\uFFFE/g, '</u>');
204    
205    // 10. 还原表格内的换行符
206    md = md.replace(/\[\[BR\]\]/g, '<br>');
207  
208    return md.trim();
209  }
210  
211  const IMAGE_BASE = 'https://api2.mubu.com/v3';
212  
213  function imageUrl(uri) {
214    return uri.startsWith('http') ? uri : `${IMAGE_BASE}/${uri}`;
215  }
216  
217  function taskPrefix(node) {
218    if (!node.taskStatus) return '';
219    return node.taskStatus === 2 ? '[x] ' : '[ ] ';
220  }
221  
222  function taskMeta(node) {
223    const parts = [];
224    if (node.deadline) {
225      const ts = formatDate(node.deadline * 1000);
226      parts.push(`📅 ${node.deadlineType === 'date' ? ts.slice(0, 10) : ts}`);
227    }
228    if (node.remindAt) parts.push(`⏰ ${formatDate(node.remindAt * 1000)}`);
229    return parts.length ? ' ' + parts.join(' ') : '';
230  }
231  
232  /** 递归将节点树渲染为缩进纯文本 */
233  export function nodesToText(nodes, depth = 0) {
234    const lines = [];
235    for (const node of nodes) {
236      const indent = '  '.repeat(depth);
237      const emoji = node.emoji ? node.emoji + ' ' : '';
238      const text = htmlToText(node.text);
239      const prefix = taskPrefix(node);
240      const meta = taskMeta(node);
241      if (text || emoji || prefix) {
242        if (text.includes('\n')) {
243          const [first, ...rest] = text.split('\n');
244          lines.push(indent + prefix + emoji + first);
245          for (const line of rest) lines.push(indent + '  ' + line);
246          if (meta) lines.push(indent + '  ' + meta.trim());
247        } else {
248          lines.push(indent + prefix + emoji + text + meta);
249        }
250      }
251      if (node.note) {
252        const noteText = htmlToText(node.note);
253        for (const line of noteText.split('\n')) lines.push(indent + '  ' + line);
254      }
255      if (node.images?.length) {
256        for (const img of node.images) {
257          lines.push(indent + `[图片: ${imageUrl(img.uri)}]`);
258        }
259      }
260      if (node.children?.length) {
261        lines.push(nodesToText(node.children, depth + 1));
262      }
263    }
264    return lines.filter(Boolean).join('\n');
265  }
266  
267  /** 递归将节点树渲染为 Markdown(大纲 = 缩进列表,不映射为标题) */
268  export function nodesToMarkdown(nodes, depth = 0) {
269    const lines = [];
270    for (const node of nodes) {
271      const text = htmlToMarkdown(node.text);
272      if (!text && !node.images?.length && !node.note && !node.emoji) continue;
273  
274      const indent = '  '.repeat(depth);
275      const emoji = node.emoji ? node.emoji + ' ' : '';
276      const prefix = taskPrefix(node);
277      const meta = taskMeta(node);
278      if (text || emoji || prefix) {
279        if (text.includes('\n')) {
280          const [first, ...rest] = text.split('\n');
281          lines.push(indent + '- ' + prefix + emoji + first);
282          const continuation = indent + '  ';
283          for (const line of rest) lines.push(continuation + line);
284          if (meta) lines.push(continuation + meta.trim());
285        } else {
286          lines.push(indent + '- ' + prefix + emoji + text + meta);
287        }
288      }
289      if (node.note) {
290        const noteLines = htmlToMarkdown(node.note).split('\n');
291        for (const line of noteLines) lines.push(indent + '  > ' + line);
292      }
293      if (node.images?.length) {
294        for (const img of node.images) {
295          lines.push(indent + `  ![image](${imageUrl(img.uri)})`);
296        }
297      }
298  
299      if (node.children?.length) {
300        lines.push(nodesToMarkdown(node.children, depth + 1));
301      }
302    }
303    return lines.filter(Boolean).join('\n');
304  }