spb/note-spboucher-ai
Public
1/** DOCX → HTML (mammoth, images stored as attachments) → Markdown (turndown) → page. */2import TurndownService from "turndown";3import { createAttachment, type ImportCtx } from "./pages";4import { importMarkdown } from "./markdown";56export async function docxToMarkdown(ctx: ImportCtx, buf: Buffer, pageId: string | null): Promise<{ markdown: string; images: number }> {7 const mammoth = await import("mammoth");8 let images = 0;9 const res = await mammoth.convertToHtml({ buffer: buf }, {10 styleMap: ["p[style-name='Title'] => h1:fresh", "p[style-name='Subtitle'] => h2:fresh", "p[style-name='Quote'] => blockquote:fresh", "p[style-name='Code'] => pre:fresh"],11 convertImage: mammoth.images.imgElement(async (image) => { const b = await image.readAsBuffer(); const ext = image.contentType.split("/")[1]?.replace("jpeg", "jpg") ?? "png"; const a = await createAttachment(ctx, Buffer.from(b), `image-${++images}.${ext}`, image.contentType, pageId); return { src: `/api/files/${a.id}` }; }),12 });13 const td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-", emDelimiter: "*" });14 td.keep(["sub", "sup"]);15 td.addRule("strike", { filter: ["s", "del", "strike" as any], replacement: (c) => `~~${c}~~` });16 td.addRule("table", { filter: "table", replacement: (_c, node) => { const rows = [...(node as HTMLElement).querySelectorAll("tr")].map((tr) => [...tr.querySelectorAll("th,td")].map((td) => td.textContent?.replace(/\s+/g, " ").trim().replace(/\|/g, "\\|") ?? "")); if (!rows.length) return ""; const w = Math.max(...rows.map((r) => r.length)); const pad = (r: string[]) => [...r, ...Array(w - r.length).fill("")]; return `\n\n| ${pad(rows[0]).join(" | ")} |\n| ${pad(rows[0]).map(() => "---").join(" | ")} |\n${rows.slice(1).map((r) => `| ${pad(r).join(" | ")} |`).join("\n")}\n\n`; } });17 return { markdown: td.turndown(res.value), images };18}19export async function importDocx(ctx: ImportCtx, buf: Buffer, opts: { filename: string; parentId?: string | null }) {20 const { markdown, images } = await docxToMarkdown(ctx, buf, null);21 const title = opts.filename.replace(/\.docx$/i, "");22 const firstH1 = markdown.match(/^#\s+(.+)$/m);23 const md = firstH1 && firstH1[1].trim().toLowerCase() === title.toLowerCase() ? markdown.replace(firstH1[0], "") : markdown;24 const page = await importMarkdown(ctx, `---\ntitle: ${JSON.stringify(title)}\n---\n${md}`, { filename: opts.filename, parentId: opts.parentId, section: "documents" });25 const original = await createAttachment(ctx, buf, opts.filename, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", page.id);26 return { page, images, attachmentId: original.id };27}28