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 + ` })`); 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 }