const INDENT_PREFIX = "\t";
export class ContentConverter {
xmlToMarkdown(xml: string): string {
if (!xml) {
return "";
}
let md = xml.replace(/\r\n/g, "\n");
// 任务列表
md = md.replace(/<input type="checkbox" checked="true" \/>/g, "- [x] ");
md = md.replace(/<input type="checkbox" \/>/g, "- [ ] ");
// 无序列表
md = md.replace(/<bullet indent="1" \/>/g, "- ");
md = md.replace(/<bullet indent="2" \/>/g, `${INDENT_PREFIX}- `);
// 粗体
md = md.replace(/<b>(.*?)<\/b>/gs, "**$1**");
// 图片
md = md.replace(/<img fileid="(.*?)" imgshow=".*?" imgdes=".*?" \/>/g, "");
// 音频
md = md.replace(/<sound fileid="(.*?)" \/>/g, "[🔊 音频: $1]");
// 普通文本
md = md.replace(/<text indent="1">(.*?)<\/text>/gs, (_match, text) => sanitizeMarkdownText(text));
md = md.replace(/<text indent="2">(.*?)<\/text>/gs, (_match, text) => `${INDENT_PREFIX}${sanitizeMarkdownText(text)}`);
// 异常缩进直接去掉
md = md.replace(/<text indent="NaN">.*?<\/text>/gs, "");
// 移除多余 XML 标签
md = md.replace(/<0\/>/g, "");
md = md.replace(/<new-format\/>/g, "");
// 清理任何残留尖括号标签
md = md.replace(/<\/?[a-zA-Z0-9-]+[^>]*>/g, "");
return md
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trim();
}
markdownToXml(markdown: string, uploadedImages: Map<string, string> = new Map()): string {
if (!markdown) {
return "";
}
const xmlLines: string[] = [];
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
for (const rawLine of lines) {
const line = rawLine.trimEnd();
// 图片:支持 minote 协议和从上传映射获取
const imageMatch = line.match(/!\[[^\]]*\]\(([^)]+)\)/);
if (imageMatch) {
const [, target] = imageMatch;
if (target) {
const fileId = resolveImageFileId(target, uploadedImages);
if (fileId) {
xmlLines.push(`<img fileid="${fileId}" imgshow="0" imgdes="" />`);
continue;
}
}
}
if (/^- \[x\] /.test(line)) {
const text = line.replace(/^- \[x\] /, "");
xmlLines.push(`<input type="checkbox" checked="true" />${escapeXmlText(text)}`);
continue;
}
if (/^- \[ \] /.test(line)) {
const text = line.replace(/^- \[ \] /, "");
xmlLines.push(`<input type="checkbox" />${escapeXmlText(text)}`);
continue;
}
if (/^\t- /.test(line)) {
const text = line.replace(/^\t- /, "");
xmlLines.push(`<bullet indent="2" />${escapeXmlText(text)}`);
continue;
}
if (/^- /.test(line)) {
const text = line.replace(/^- /, "");
xmlLines.push(`<bullet indent="1" />${escapeXmlText(text)}`);
continue;
}
const indent = line.startsWith("\t") ? "2" : "1";
const normalized = line.replace(/^\t/, "");
const processed = normalized.replace(/\*\*(.*?)\*\*/g, (_match, boldText) => `<b>${escapeXmlText(boldText)}</b>`);
xmlLines.push(`<text indent="${indent}">${escapeXmlText(processed, true)}</text>`);
}
return xmlLines.join("\n");
}
}
function sanitizeMarkdownText(text: string | undefined): string {
return decodeHtmlEntities(text ?? "").replace(/\s+$/g, "");
}
function resolveImageFileId(target: string, uploadedImages: Map<string, string>): string | undefined {
if (target.startsWith("minote://image/")) {
return target.substring("minote://image/".length);
}
return uploadedImages.get(target);
}
function escapeXmlText(text: string, allowTags = false): string {
if (!text) {
return "";
}
const escaped = text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
if (allowTags) {
// allowTags = true 表示文本内可能已包含格式化标签(如 <b>),这里恢复尖括号
return escaped.replace(/<(\/?)b>/g, "<$1b>");
}
return escaped;
}
const htmlEntityMap: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
};
function decodeHtmlEntities(value: string): string {
return value.replace(/&([a-z]+);/gi, (_match, entity: string) => {
const decoded = htmlEntityMap[entity.toLowerCase()];
return decoded ?? `&${entity};`;
});
}