feat: v2.1.0 界面与凭据改进 —— 单一 API/MCP Key、邮件 Markdown 正文、/inboxs 域名筛选、暗色主题
- 单一 Key:API 与 MCP 共用一个 wmh_ Key,凭据迁移至 settings 表(0004 删除 api_credentials) - Markdown:入库时 HTML→Markdown(mailhook 同思路),webhook payload 新增 markdown 字段 - /emails 路由改为 /inboxs 并支持域名筛选;设置按钮移入头像下拉 - 主题切换按钮(头像左侧,亮/暗持久化);设置模态仅 全局 Webhook/账号密码 两 tab 且高度限屏 - 列表操作列按钮加 SVG 图标;mailboxes 去 Webhook 列;最近邮件加收件人列
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* HTML -> Markdown 转换(参考 mailhook(Rust) mail.rs 的实现思路):
|
||||
* 遍历 DOM,按块级/行内语义转换,表格/列表/引用/代码块均有映射;
|
||||
* 输出折叠连续空行。用于邮件正文提取(docs/07 webhook payload 的 markdown 字段)。
|
||||
*/
|
||||
|
||||
import { parse, HTMLElement, TextNode, type Node as HtmlNode } from "node-html-parser";
|
||||
|
||||
/** # 成员变量: 不参与转换的标签(内容整体丢弃) **/
|
||||
const SKIP_TAGS = new Set([
|
||||
"script",
|
||||
"style",
|
||||
"head",
|
||||
"noscript",
|
||||
"template",
|
||||
"meta",
|
||||
"link",
|
||||
"title",
|
||||
]);
|
||||
|
||||
/** # 函数
|
||||
* 将 HTML 片段转换为 Markdown
|
||||
* 参数: html - HTML 片段(可为完整文档)
|
||||
* 返回值: Markdown 文本;空内容返回空串
|
||||
*/
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
let root: HTMLElement;
|
||||
try {
|
||||
root = parse(html);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
const blocks: string[] = [];
|
||||
renderChildren(root, blocks, 0);
|
||||
return collapseBlankLines(
|
||||
blocks
|
||||
.filter((b) => b.trim().length > 0)
|
||||
.join("\n\n"),
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 压缩连续空行为单个空行
|
||||
*/
|
||||
function collapseBlankLines(s: string): string {
|
||||
const lines = s.split("\n");
|
||||
const out: string[] = [];
|
||||
let blank = 0;
|
||||
for (const line of lines) {
|
||||
if (line.trim().length === 0) {
|
||||
blank += 1;
|
||||
if (blank > 1) continue;
|
||||
} else {
|
||||
blank = 0;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 折叠空白(换行/多空格折叠为单空格)
|
||||
*/
|
||||
function normalizeWs(s: string): string {
|
||||
return s.split(/\s+/).filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 将 归一为空格
|
||||
*/
|
||||
function collapseNbsp(s: string): string {
|
||||
return s.replace(/\u00a0/g, " ");
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 取元素行内文本(递归处理 strong/em/code/a 等行内语义)
|
||||
*/
|
||||
function inlineText(el: HTMLElement): string {
|
||||
const buf: string[] = [];
|
||||
renderInlineChildren(el, buf);
|
||||
return normalizeWs(buf.join(""));
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 渲染元素子节点为块级 Markdown 片段列表
|
||||
*/
|
||||
function renderChildren(el: HTMLElement, blocks: string[], indent: number): void {
|
||||
for (const child of el.childNodes) {
|
||||
renderNode(child, blocks, indent);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 渲染单个节点(元素或文本)
|
||||
*/
|
||||
function renderNode(node: HtmlNode, blocks: string[], indent: number): void {
|
||||
if (node instanceof HTMLElement) {
|
||||
const tag = node.rawTagName?.toLowerCase() ?? "";
|
||||
if (SKIP_TAGS.has(tag)) return;
|
||||
renderElement(node, tag, blocks, indent);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType === 3) {
|
||||
// 文本节点
|
||||
const txt = normalizeWs(collapseNbsp(node.text ?? ""));
|
||||
if (txt) blocks.push(" ".repeat(indent) + txt);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 块级元素映射:h1-h6/p/hr/pre/blockquote/ul/ol/table/img 等
|
||||
*/
|
||||
function renderElement(el: HTMLElement, tag: string, blocks: string[], indent: number): void {
|
||||
const pad = " ".repeat(indent);
|
||||
switch (tag) {
|
||||
case "h1":
|
||||
case "h2":
|
||||
case "h3":
|
||||
case "h4":
|
||||
case "h5":
|
||||
case "h6": {
|
||||
const level = Number(tag.slice(1)) || 1;
|
||||
blocks.push(`${pad}${"#".repeat(level)} ${inlineText(el)}`);
|
||||
return;
|
||||
}
|
||||
case "p": {
|
||||
const buf: string[] = [];
|
||||
renderInlineChildren(el, buf);
|
||||
const txt = normalizeWs(buf.join(""));
|
||||
if (txt) blocks.push(pad + txt);
|
||||
return;
|
||||
}
|
||||
case "br":
|
||||
blocks.push("");
|
||||
return;
|
||||
case "hr":
|
||||
blocks.push(`${pad}---`);
|
||||
return;
|
||||
case "pre": {
|
||||
const code = collectPreText(el);
|
||||
blocks.push(`${pad}\`\`\`\n${code}\n${pad}\`\`\``);
|
||||
return;
|
||||
}
|
||||
case "blockquote": {
|
||||
const inner: string[] = [];
|
||||
renderChildren(el, inner, 0);
|
||||
const quoted = inner
|
||||
.join("\n\n")
|
||||
.split("\n")
|
||||
.map((l) => `${pad}> ${l}`)
|
||||
.join("\n");
|
||||
blocks.push(quoted);
|
||||
return;
|
||||
}
|
||||
case "ul":
|
||||
case "ol": {
|
||||
let idx = 0;
|
||||
for (const li of el.querySelectorAll(":scope > li")) {
|
||||
idx += 1;
|
||||
const own: string[] = [];
|
||||
// 行内内容(跳过嵌套列表)
|
||||
for (const c of li.childNodes) {
|
||||
const isList =
|
||||
c instanceof HTMLElement && ["ul", "ol"].includes(c.rawTagName?.toLowerCase() ?? "");
|
||||
if (isList) continue;
|
||||
renderInlineNode(c, own);
|
||||
}
|
||||
const marker = tag === "ol" ? `${idx}.` : "-";
|
||||
blocks.push(`${pad}${marker} ${normalizeWs(own.join(""))}`);
|
||||
// 嵌套列表
|
||||
for (const sub of li.querySelectorAll(":scope > ul, :scope > ol")) {
|
||||
renderElement(sub, sub.rawTagName.toLowerCase(), blocks, indent + 1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "table": {
|
||||
const md = tableMarkdown(el, indent);
|
||||
if (md) blocks.push(md);
|
||||
return;
|
||||
}
|
||||
case "img": {
|
||||
const src = el.getAttribute("src") ?? "";
|
||||
const alt = el.getAttribute("alt") ?? "";
|
||||
if (src) blocks.push(`${pad}`);
|
||||
return;
|
||||
}
|
||||
case "a": {
|
||||
const href = el.getAttribute("href") ?? "";
|
||||
const txt = inlineText(el);
|
||||
if (txt) blocks.push(`${pad}[${txt}](${href})`);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
// 通用块级容器:递归处理子节点
|
||||
renderChildren(el, blocks, indent);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 收集 pre 元素保留原始换行的文本
|
||||
*/
|
||||
function collectPreText(el: HTMLElement): string {
|
||||
const out: string[] = [];
|
||||
const walk = (node: HtmlNode) => {
|
||||
if (node instanceof HTMLElement) {
|
||||
if ((node.rawTagName ?? "").toLowerCase() === "br") out.push("\n");
|
||||
for (const c of node.childNodes) walk(c);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType === 3) out.push(collapseNbsp(node.text ?? ""));
|
||||
};
|
||||
for (const c of el.childNodes) walk(c);
|
||||
return out.join("").replace(/^\n+|\n+$/g, "");
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 渲染元素子节点为行内 Markdown
|
||||
*/
|
||||
function renderInlineChildren(el: HTMLElement, out: string[]): void {
|
||||
for (const child of el.childNodes) {
|
||||
renderInlineNode(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 行内节点映射:strong/em/del/code/br/a/img;行内上下文的块级标签降级为空格分隔
|
||||
*/
|
||||
function renderInlineNode(node: HtmlNode, out: string[]): void {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
if (node.nodeType === 3) out.push(collapseNbsp(node.text ?? ""));
|
||||
return;
|
||||
}
|
||||
const tag = node.rawTagName?.toLowerCase() ?? "";
|
||||
if (SKIP_TAGS.has(tag)) return;
|
||||
const inner = (): string => {
|
||||
const buf: string[] = [];
|
||||
renderInlineChildren(node, buf);
|
||||
return buf.join("").trim();
|
||||
};
|
||||
switch (tag) {
|
||||
case "strong":
|
||||
case "b": {
|
||||
const buf = inner();
|
||||
if (buf) out.push(`**${buf}**`);
|
||||
return;
|
||||
}
|
||||
case "em":
|
||||
case "i": {
|
||||
const buf = inner();
|
||||
if (buf) out.push(`*${buf}*`);
|
||||
return;
|
||||
}
|
||||
case "del":
|
||||
case "s":
|
||||
case "strike": {
|
||||
const buf = inner();
|
||||
if (buf) out.push(`~~${buf}~~`);
|
||||
return;
|
||||
}
|
||||
case "code": {
|
||||
const buf = inner();
|
||||
if (buf) out.push(`\`${buf}\``);
|
||||
return;
|
||||
}
|
||||
case "br":
|
||||
out.push("\n");
|
||||
return;
|
||||
case "a": {
|
||||
const href = node.getAttribute("href") ?? "";
|
||||
const buf = normalizeWs(node.textContent ?? "");
|
||||
out.push(buf ? `[${buf}](${href})` : `<${href}>`);
|
||||
return;
|
||||
}
|
||||
case "img": {
|
||||
const src = node.getAttribute("src") ?? "";
|
||||
const alt = node.getAttribute("alt") ?? "";
|
||||
out.push(``);
|
||||
return;
|
||||
}
|
||||
case "p":
|
||||
case "div":
|
||||
case "tr":
|
||||
case "li":
|
||||
case "h1":
|
||||
case "h2":
|
||||
case "h3":
|
||||
case "h4":
|
||||
case "h5":
|
||||
case "h6":
|
||||
case "table":
|
||||
case "blockquote":
|
||||
case "pre": {
|
||||
// 行内上下文里出现的块级标签:换行后继续,避免内容粘连
|
||||
out.push(" ");
|
||||
renderInlineChildren(node, out);
|
||||
out.push(" ");
|
||||
return;
|
||||
}
|
||||
default:
|
||||
renderInlineChildren(node, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 表格 -> Markdown 管道表(首行作表头,列数对齐,| 转义)
|
||||
*/
|
||||
function tableMarkdown(table: HTMLElement, indent: number): string | null {
|
||||
const pad = " ".repeat(indent);
|
||||
const rows: string[][] = [];
|
||||
for (const tr of table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")) {
|
||||
const cells: string[] = [];
|
||||
for (const cell of tr.querySelectorAll(":scope > th, :scope > td")) {
|
||||
cells.push(inlineText(cell));
|
||||
}
|
||||
if (cells.length > 0) rows.push(cells);
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
const width = Math.max(...rows.map((r) => r.length));
|
||||
const padRow = (r: string[]) => {
|
||||
const c = [...r];
|
||||
while (c.length < width) c.push("");
|
||||
return c;
|
||||
};
|
||||
const esc = (s: string) => s.replace(/\|/g, "\\|");
|
||||
const lines: string[] = [];
|
||||
lines.push(`${pad}| ${padRow(rows[0]).map(esc).join(" | ")} |`);
|
||||
lines.push(`${pad}|${new Array(width).fill(" --- ").join("|")}|`);
|
||||
for (const r of rows.slice(1)) {
|
||||
lines.push(`${pad}| ${padRow(r).map(esc).join(" | ")} |`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user