From 000f2f98fb29824bb9e2224b8e1d1f62ed46953c Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 2 Aug 2026 20:42:05 -0400 Subject: [PATCH] feat: Add TextPack (.textpack) support (#13235) * feat: Add TextPack (.textpack) import support Adds a single-document importer for TextPack, the zipped variant of TextBundle used by Bear, Ulysses, iA Writer and others. Bare .textbundle directories are not supported, as a directory cannot be delivered through a browser file input. The bundle's text entry may use any extension, per the spec, with info.json's type deciding whether it can be read as markdown. Assets are inlined as data URIs for the existing attachment pipeline to pick up, bounded by the attachment size limit and a memory ceiling, and only for media types markdown-it accepts as a link destination. Also fixes an existing bug where a file embedded in an HTML or email import as a data URI was stored in the document as base64 rather than being uploaded as an attachment. Co-Authored-By: Claude Opus 5 (1M context) * Refactor to individual converters * refactor --------- Co-authored-by: Claude Opus 5 (1M context) --- app/actions/definitions/documents.tsx | 22 + app/components/DocumentDownload.tsx | 7 + app/components/ExportDialog.tsx | 7 + app/components/ImportDocumentDialog.tsx | 5 + .../components/FileOperationListItem.tsx | 1 + app/stores/DocumentsStore.ts | 1 + server/commands/documentImporter.test.ts | 61 ++ server/commands/documentImporter.ts | 3 +- server/converters/BaseConverter.ts | 50 ++ server/converters/CsvConverter.ts | 117 +++ .../DocumentConverter.test.ts | 310 +++++++ server/converters/DocumentConverter.ts | 268 +++++++ server/converters/DocxConverter.ts | 27 + server/converters/HtmlPreprocessor.ts | 162 ++++ server/converters/MimeArchiveConverter.ts | 201 +++++ server/converters/TextPackConverter.ts | 371 +++++++++ .../models/helpers/ProseMirrorHelper.test.ts | 73 ++ server/models/helpers/ProsemirrorHelper.tsx | 67 +- server/models/helpers/TextBundleHelper.ts | 74 ++ .../FileOperationCreatedProcessor.ts | 6 + server/queues/tasks/ExportDocumentTreeTask.ts | 63 +- .../tasks/ExportTextBundleZipTask.test.ts | 253 ++++++ .../queues/tasks/ExportTextBundleZipTask.ts | 31 + .../tasks/MarkdownAPIImportTask.test.ts | 18 + server/queues/tasks/MarkdownAPIImportTask.ts | 25 +- server/routes/api/documents/documents.test.ts | 73 ++ server/routes/api/documents/documents.ts | 62 +- .../fixtures/textbundle-traversal.textpack | Bin 0 -> 479 bytes server/test/support.ts | 30 + server/utils/DocumentConverter.ts | 757 ------------------ server/utils/ZipHelper.test.ts | 34 + server/utils/ZipHelper.ts | 217 ++--- shared/i18n/locales/en_US/translation.json | 3 + shared/types.ts | 2 + shared/utils/markdown.test.ts | 53 ++ shared/utils/markdown.ts | 54 ++ shared/utils/parseTitle.ts | 35 +- 37 files changed, 2634 insertions(+), 909 deletions(-) create mode 100644 server/converters/BaseConverter.ts create mode 100644 server/converters/CsvConverter.ts rename server/{utils => converters}/DocumentConverter.test.ts (68%) create mode 100644 server/converters/DocumentConverter.ts create mode 100644 server/converters/DocxConverter.ts create mode 100644 server/converters/HtmlPreprocessor.ts create mode 100644 server/converters/MimeArchiveConverter.ts create mode 100644 server/converters/TextPackConverter.ts create mode 100644 server/models/helpers/TextBundleHelper.ts create mode 100644 server/queues/tasks/ExportTextBundleZipTask.test.ts create mode 100644 server/queues/tasks/ExportTextBundleZipTask.ts create mode 100644 server/test/fixtures/textbundle-traversal.textpack delete mode 100644 server/utils/DocumentConverter.ts create mode 100644 shared/utils/markdown.test.ts diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 62c175201a..72f61946de 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -771,6 +771,27 @@ export const downloadDocumentAsHTML = createAction({ }, }); +export const downloadDocumentAsTextBundle = createAction({ + name: ({ t }) => t("Download as TextBundle"), + analyticsName: "Download document as TextBundle", + section: ActiveDocumentSection, + keywords: "textbundle textpack bear ulysses export", + icon: , + visible: ({ activeDocumentId, stores }) => + !!activeDocumentId && stores.policies.abilities(activeDocumentId).download, + perform: async ({ activeDocumentId, stores }) => { + if (!activeDocumentId) { + return; + } + + const document = stores.documents.get(activeDocumentId); + await document?.download({ + contentType: ExportContentType.TextBundle, + includeChildDocuments: false, + }); + }, +}); + export const downloadDocumentAsPDF = createAction({ name: ({ t }) => t("Download as PDF"), analyticsName: "Download document as PDF", @@ -1740,6 +1761,7 @@ export const rootDocumentActions = [ downloadDocument, downloadDocumentAsMarkdown, downloadDocumentAsHTML, + downloadDocumentAsTextBundle, downloadDocumentAsPDF, copyDocumentLink, copyDocumentShareLink, diff --git a/app/components/DocumentDownload.tsx b/app/components/DocumentDownload.tsx index 0ebbc4c51f..aea1af32d2 100644 --- a/app/components/DocumentDownload.tsx +++ b/app/components/DocumentDownload.tsx @@ -90,6 +90,13 @@ export const DocumentDownload = observer(({ document, onSubmit }: Props) => { ), value: ExportContentType.Html, }, + { + title: "TextBundle", + description: t( + "A file containing the selected documents and their images in TextBundle format." + ), + value: ExportContentType.TextBundle, + }, ]; if (env.PDF_EXPORT_ENABLED) { diff --git a/app/components/ExportDialog.tsx b/app/components/ExportDialog.tsx index 3aeea2befd..5e8bfae5f1 100644 --- a/app/components/ExportDialog.tsx +++ b/app/components/ExportDialog.tsx @@ -103,6 +103,13 @@ function ExportDialog({ collection, onSubmit }: Props) { ), value: FileOperationFormat.HTMLZip, }, + { + title: "TextBundle", + description: t( + "A ZIP file containing each document and its images as a TextBundle." + ), + value: FileOperationFormat.TextBundleZip, + }, { title: "JSON", description: t( diff --git a/app/components/ImportDocumentDialog.tsx b/app/components/ImportDocumentDialog.tsx index b26dda12a4..e23afa4d7b 100644 --- a/app/components/ImportDocumentDialog.tsx +++ b/app/components/ImportDocumentDialog.tsx @@ -70,6 +70,11 @@ export const ImportDocumentDialog = observer(function ImportDocumentDialog({ extensions: ".eml", icon: , }, + { + name: "TextPack", + extensions: ".textpack", + icon: , + }, ], [t] ); diff --git a/app/scenes/Settings/components/FileOperationListItem.tsx b/app/scenes/Settings/components/FileOperationListItem.tsx index 55e2edb7e2..33df9f6058 100644 --- a/app/scenes/Settings/components/FileOperationListItem.tsx +++ b/app/scenes/Settings/components/FileOperationListItem.tsx @@ -58,6 +58,7 @@ const FileOperationListItem = ({ fileOperation }: Props) => { [FileOperationFormat.Notion]: "Notion", [FileOperationFormat.MarkdownZip]: "Markdown", [FileOperationFormat.HTMLZip]: "HTML", + [FileOperationFormat.TextBundleZip]: "TextBundle", [FileOperationFormat.PDF]: "PDF", }; diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index a4fabdda35..eb4ea96e27 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -69,6 +69,7 @@ export default class DocumentsStore extends Store { ".mhtml", ".mht", ".eml", + ".textpack", "text/csv", "text/tab-separated-values", "text/markdown", diff --git a/server/commands/documentImporter.test.ts b/server/commands/documentImporter.test.ts index 60d7c3c224..b5bd20d9a7 100644 --- a/server/commands/documentImporter.test.ts +++ b/server/commands/documentImporter.test.ts @@ -5,6 +5,7 @@ import { createContext } from "@server/context"; import Attachment from "@server/models/Attachment"; import { sequelize } from "@server/storage/database"; import { buildUser } from "@server/test/factories"; +import { buildZip } from "@server/test/support"; import documentImporter from "./documentImporter"; vi.mock("@server/storage/files"); @@ -38,6 +39,66 @@ describe("documentImporter", () => { expect(response.title).toEqual("images"); }); + it("should convert a TextPack bundle to markdown and turn its assets into attachments", async () => { + const user = await buildUser(); + const fileName = "My Note.textpack"; + const content = await buildZip({ + "Note.textbundle/info.json": JSON.stringify({ + version: 2, + type: "net.daringfireball.markdown", + }), + "Note.textbundle/text.markdown": + "# My Note\n\nHello world!\n\n![a photo](assets/image.png)\n", + "Note.textbundle/assets/image.png": Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64" + ), + }); + + const response = await sequelize.transaction((transaction) => + documentImporter({ + user, + mimeType: "application/octet-stream", + fileName, + content, + ctx: createContext({ user, transaction }), + }) + ); + const attachments = await Attachment.count({ + where: { + teamId: user.teamId, + }, + }); + expect(attachments).toEqual(1); + expect(response.text).toContain("Hello world!"); + expect(response.text).toContain("![a photo](/api/attachments.redirect?id="); + expect(response.title).toEqual("My Note"); + }); + + it("should turn a file embedded in HTML into an attachment", async () => { + const user = await buildUser(); + const html = `

Notes

the spec

`; + + const response = await sequelize.transaction((transaction) => + documentImporter({ + user, + mimeType: "text/html", + fileName: "notes.html", + content: html, + ctx: createContext({ user, transaction }), + }) + ); + + const attachments = await Attachment.count({ + where: { + teamId: user.teamId, + }, + }); + expect(attachments).toEqual(1); + expect(response.text).toContain("[the spec](/api/attachments.redirect?id="); + expect(response.text).not.toContain("data:application/pdf"); + }); + it("should not strip content after period in title", async () => { const user = await buildUser(); const fileName = "01. test"; diff --git a/server/commands/documentImporter.ts b/server/commands/documentImporter.ts index 48f5a94d61..456d191156 100644 --- a/server/commands/documentImporter.ts +++ b/server/commands/documentImporter.ts @@ -7,7 +7,7 @@ import { traceFunction } from "@server/logging/tracing"; import type { User } from "@server/models"; import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper"; import type { APIContext } from "@server/types"; -import { DocumentConverter } from "@server/utils/DocumentConverter"; +import { DocumentConverter } from "@server/converters/DocumentConverter"; import { InvalidRequestError } from "../errors"; type Props = { @@ -64,6 +64,7 @@ async function documentImporter({ "mhtml", "mht", "eml", + "textpack", ...(mime.extensions[mimeType] ?? []), ]; const fileTitle = fileName.replace( diff --git a/server/converters/BaseConverter.ts b/server/converters/BaseConverter.ts new file mode 100644 index 0000000000..c0125e6c1c --- /dev/null +++ b/server/converters/BaseConverter.ts @@ -0,0 +1,50 @@ +import yaml from "js-yaml"; + +/** + * Base class for the individual file format converters, holding the handling + * that is common to reading any incoming file. + */ +export abstract class BaseConverter { + /** + * Convert a Buffer to a string. + * + * @param content The content as a Buffer or string. + * @returns The content as a string. + */ + protected static bufferToString(content: Buffer | string): string { + return typeof content === "string" ? content : content.toString("utf8"); + } + + /** + * Parse and convert frontmatter to a YAML codeblock. + * + * @param content The markdown content that may contain frontmatter. + * @returns The markdown content with frontmatter converted to a YAML codeblock. + */ + protected static processFrontmatter(content: string): string { + // Frontmatter must start at the beginning of the document + const frontmatterRegex = /^---\n([\s\S]*?)\n---(?:\n|$)/; + const match = content.match(frontmatterRegex); + + if (!match) { + return content; + } + + const frontmatterContent = match[1]; + const remainingContent = content.slice(match[0].length); + + // Validate that the frontmatter is valid YAML + try { + yaml.load(frontmatterContent); + } catch { + // If it's not valid YAML, return content unchanged + return content; + } + + // Convert frontmatter to a YAML codeblock + const codeBlockDelimiter = "```"; + const yamlCodeblock = `${codeBlockDelimiter}yaml\n${frontmatterContent}\n${codeBlockDelimiter}\n\n`; + + return yamlCodeblock + remainingContent; + } +} diff --git a/server/converters/CsvConverter.ts b/server/converters/CsvConverter.ts new file mode 100644 index 0000000000..8e76601c83 --- /dev/null +++ b/server/converters/CsvConverter.ts @@ -0,0 +1,117 @@ +import { parse } from "@fast-csv/parse"; +import { escapeRegExp } from "es-toolkit/compat"; +import { FileImportError } from "@server/errors"; +import { BaseConverter } from "./BaseConverter"; + +/** + * Converts delimiter separated values (CSV, TSV) to a markdown table. + */ +export class CsvConverter extends BaseConverter { + /** + * Convert a CSV file to a markdown table. + * + * @param content The CSV file content. + * @returns A markdown table representation. + */ + public static async toMarkdown(content: Buffer | string): Promise { + return new Promise((resolve, reject) => { + const text = this.bufferToString(content).trim(); + const textLines = text.split("\n"); + + // Find the first non-empty line to determine the delimiter + const firstNonEmptyLine = + textLines.find((line) => line.trim().length > 0) || ""; + + // Determine the separator used in the CSV file based on number of occurrences of each separator on first line + const delimiter = [";", ",", "\t"].reduce( + (acc, separator) => { + const count = ( + firstNonEmptyLine.match(new RegExp(escapeRegExp(separator), "g")) || + [] + ).length; + return count > acc.count ? { count, separator } : acc; + }, + { count: 0, separator: "," } + ).separator; + + const lines: string[][] = []; + const stream = parse({ delimiter }) + .on("error", (error) => { + reject( + FileImportError(`There was an error parsing the CSV file: ${error}`) + ); + }) + .on("data", (row) => lines.push(row)) + .on("end", () => { + // Filter out completely empty rows + const nonEmptyLines = lines.filter((row) => + row.some((cell) => cell.trim() !== "") + ); + + if (nonEmptyLines.length === 0) { + resolve(""); + return; + } + + // Check if all rows have a trailing empty cell (trailing comma artifact) + // Only trim if ALL non-empty rows end with an empty cell + let trimmedLines = nonEmptyLines; + while ( + trimmedLines.length > 0 && + trimmedLines.every( + (row) => row.length > 0 && row[row.length - 1].trim() === "" + ) + ) { + trimmedLines = trimmedLines.map((row) => row.slice(0, -1)); + } + + // Find the most common column count + const columnCounts = new Map(); + for (const row of trimmedLines) { + if (row.length > 0) { + columnCounts.set( + row.length, + (columnCounts.get(row.length) || 0) + 1 + ); + } + } + + // Get the column count that appears most frequently + let expectedColumns = 0; + let maxFrequency = 0; + for (const [count, frequency] of columnCounts) { + if (frequency > maxFrequency) { + maxFrequency = frequency; + expectedColumns = count; + } + } + + // Find the first row with the expected column count (this is the header) + const headerIndex = trimmedLines.findIndex( + (row) => row.length === expectedColumns + ); + if (headerIndex === -1) { + resolve(""); + return; + } + + const headers = trimmedLines[headerIndex]; + const dataRows = trimmedLines + .slice(headerIndex + 1) + .filter((row) => row.length === expectedColumns); + + const table = dataRows + .map((cells) => `| ${cells.join(" | ")} |`) + .join("\n"); + + const headerLine = `| ${headers.join(" | ")} |`; + const separatorLine = `| ${headers.map(() => "---").join(" | ")} |`; + + resolve(`${headerLine}\n${separatorLine}\n${table}\n`); + }); + + stream.write(text); + stream.end(); + }); + } +} diff --git a/server/utils/DocumentConverter.test.ts b/server/converters/DocumentConverter.test.ts similarity index 68% rename from server/utils/DocumentConverter.test.ts rename to server/converters/DocumentConverter.test.ts index f8919a01ce..473fe53222 100644 --- a/server/utils/DocumentConverter.test.ts +++ b/server/converters/DocumentConverter.test.ts @@ -1,10 +1,29 @@ import path from "node:path"; import fs from "fs-extra"; +import { buildZip } from "@server/test/support"; import { DocumentConverter } from "./DocumentConverter"; const fixture = (fileName: string) => fs.readFile(path.resolve(__dirname, "..", "test", "fixtures", fileName)); +/** A 1x1 transparent PNG, small enough to embed in a test bundle. */ +const PNG_PIXEL = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64" +); + +/** A TextBundle as an app would write it: wrapper folder, metadata and assets. */ +const BASIC_BUNDLE = { + "Note.textbundle/info.json": JSON.stringify({ + version: 2, + type: "net.daringfireball.markdown", + transient: false, + }), + "Note.textbundle/text.markdown": + "# My Note\n\nHello world!\n\n![a photo](assets/image.png)\n", + "Note.textbundle/assets/image.png": PNG_PIXEL, +}; + describe("DocumentConverter", () => { describe("convert", () => { describe("csv", () => { @@ -176,6 +195,43 @@ John,25`; expect(result.text).not.toMatch(/^🚀/); }); + it("should extract emoji leading the title", async () => { + const html = "

🚀 My Title

Content here

"; + const result = await DocumentConverter.convert( + html, + "test.html", + "text/html" + ); + + expect(result.icon).toEqual("🚀"); + expect(result.title).toEqual("My Title"); + }); + + it("should leave the body alone when the title supplied an emoji", async () => { + const html = "

🚀 My Title

🎉 Content here

"; + const result = await DocumentConverter.convert( + html, + "test.html", + "text/html" + ); + + expect(result.icon).toEqual("🚀"); + expect(result.title).toEqual("My Title"); + expect(result.text).toContain("🎉 Content here"); + }); + + it("should not treat an emoji later in the title as an icon", async () => { + const html = "

My Title 🚀

Content here

"; + const result = await DocumentConverter.convert( + html, + "test.html", + "text/html" + ); + + expect(result.icon).toBeUndefined(); + expect(result.title).toEqual("My Title 🚀"); + }); + it("should convert htm when the mime type is not recognized", async () => { const html = "

My Title

Content here

"; const result = await DocumentConverter.convert(html, "test.HTM", ""); @@ -437,6 +493,260 @@ Content`; expect(result.text).not.toContain("```yaml"); }); }); + + describe("textpack", () => { + it("should convert a wrapped TextBundle to markdown and embed its assets", async () => { + const content = await buildZip(BASIC_BUNDLE); + + const result = await DocumentConverter.convert( + content, + "My Note.textpack", + "application/octet-stream" + ); + + expect(result.title).toEqual("My Note"); + expect(result.text).toContain("Hello world!"); + expect(result.text).toContain("data:image/png;base64,"); + expect(result.text).not.toContain("assets/image.png"); + }); + + it("should lift an emoji leading the title into the icon", async () => { + const content = await buildZip({ + "Note.textbundle/text.markdown": "# 🚀 My Note\n\nHello world!\n", + }); + + const result = await DocumentConverter.convert( + content, + "My Note.textpack", + "application/octet-stream" + ); + + expect(result.icon).toEqual("🚀"); + expect(result.title).toEqual("My Note"); + expect(result.text).toContain("Hello world!"); + }); + + it("should convert a flat TextBundle (no wrapper folder, text.md) to markdown", async () => { + const content = await buildZip({ + "info.json": JSON.stringify({ + version: 1, + type: "net.daringfireball.markdown", + }), + "text.md": "# Flat Note\n\nSee ![](assets/photo.jpg) here.\n", + "assets/photo.jpg": PNG_PIXEL, + }); + + const result = await DocumentConverter.convert( + content, + "Flat.textpack", + "application/octet-stream" + ); + + expect(result.title).toEqual("Flat Note"); + expect(result.text).toContain("data:image/jpeg;base64,"); + expect(result.text).not.toContain("assets/photo.jpg"); + }); + + it("should tolerate a bundle with no info.json", async () => { + const content = await buildZip({ + "Plain.textbundle/text.txt": + "Just plain text content, no heading here.\n", + }); + + const result = await DocumentConverter.convert( + content, + "Plain.textpack", + "application/octet-stream" + ); + + expect(result.text).toContain( + "Just plain text content, no heading here." + ); + }); + + it("should fall back to extension-based routing when mimetype is unrecognized", async () => { + const content = await buildZip(BASIC_BUNDLE); + + const result = await DocumentConverter.convert( + content, + "My Note.textpack", + "application/zip" + ); + + expect(result.title).toEqual("My Note"); + }); + + it("should skip path-traversal entries and still import the legitimate text file", async () => { + const content = await fixture("textbundle-traversal.textpack"); + + const result = await DocumentConverter.convert( + content, + "Note.textpack", + "application/octet-stream" + ); + + expect(result.title).toEqual("Safe"); + expect(result.text).toContain("Content here."); + }); + + it("should reject a bundle with no recognizable text file", async () => { + const content = await buildZip({ + "Empty.textbundle/info.json": JSON.stringify({ + version: 2, + type: "net.daringfireball.markdown", + }), + "Empty.textbundle/assets/image.png": PNG_PIXEL, + }); + + await expect( + DocumentConverter.convert( + content, + "Empty.textpack", + "application/octet-stream" + ) + ).rejects.toThrow( + "TextPack file does not contain a recognizable text file" + ); + }); + + it("should reject an asset larger than the maximum, however well it compresses", async () => { + const content = await buildZip({ + "text.markdown": "# Note\n\n![big](assets/big.png)", + // Compresses to almost nothing, so the archive stays small while the + // asset is far past what may be held in memory as a data URI. + "assets/big.png": Buffer.alloc(20 * 1024 * 1024), + }); + + await expect( + DocumentConverter.convert(content, "Note.textpack", "") + ).rejects.toThrow("too large"); + }); + + it("should reject a bundle with an absurd number of entries", async () => { + const files: Record = { + "text.markdown": "# Note", + }; + for (let i = 0; i < 2001; i++) { + files[`assets/file-${i}.txt`] = "x"; + } + const content = await buildZip(files); + + await expect( + DocumentConverter.convert(content, "Note.textpack", "") + ).rejects.toThrow("TextPack file contains too many entries"); + }); + + it("should read a text file with an extension the spec leaves open", async () => { + const content = await buildZip({ + "Note.textbundle/info.json": JSON.stringify({ + version: 2, + type: "net.daringfireball.markdown", + }), + "Note.textbundle/text.fountain": "# Screenplay\n\nFade in.", + }); + + const result = await DocumentConverter.convert( + content, + "Note.textpack", + "" + ); + + expect(result.title).toEqual("Screenplay"); + expect(result.text).toContain("Fade in."); + }); + + it("should reject a bundle whose info.json declares a format we cannot read", async () => { + const content = await buildZip({ + "Note.textbundle/info.json": JSON.stringify({ + version: 2, + type: "public.rtf", + }), + "Note.textbundle/text.rtf": "{\\rtf1 hello}", + }); + + await expect( + DocumentConverter.convert(content, "Note.textpack", "") + ).rejects.toThrow("public.rtf"); + }); + + it("should reject an unreadable text extension when info.json is absent", async () => { + const content = await buildZip({ + "text.rtf": "{\\rtf1 hello}", + }); + + await expect( + DocumentConverter.convert(content, "Note.textpack", "") + ).rejects.toThrow(".rtf"); + }); + + it("should embed an asset referenced by a link carrying a title", async () => { + const content = await buildZip({ + "text.markdown": '# Note\n\n![a photo](assets/img.png "Caption")', + "assets/img.png": PNG_PIXEL, + }); + + const result = await DocumentConverter.convert( + content, + "Note.textpack", + "" + ); + + expect(result.text).toContain("data:image/png;base64,"); + expect(result.text).not.toContain("assets/img.png"); + }); + + it("should embed an asset whose destination is wrapped in angle brackets", async () => { + const content = await buildZip({ + "text.markdown": "# Note\n\n![a photo]()", + "assets/my photo.png": PNG_PIXEL, + }); + + const result = await DocumentConverter.convert( + content, + "Note.textpack", + "" + ); + + expect(result.text).toContain("data:image/png;base64,"); + expect(result.text).not.toContain("my photo.png"); + }); + + it("should embed an asset when the assets folder is not lowercased", async () => { + const content = await buildZip({ + "text.markdown": "# Note\n\n![a photo](assets/photo.png)", + "Assets/photo.png": PNG_PIXEL, + }); + + const result = await DocumentConverter.convert( + content, + "Note.textpack", + "" + ); + + expect(result.text).toContain("data:image/png;base64,"); + }); + + it("should leave an asset markdown-it cannot accept as a data URI alone", async () => { + const content = await buildZip({ + "text.markdown": + "# Note\n\n[the spec](assets/spec.pdf)\n\n![vector](assets/logo.svg)", + "assets/spec.pdf": Buffer.from("%PDF-1.4\n"), + "assets/logo.svg": Buffer.from(""), + }); + + const result = await DocumentConverter.convert( + content, + "Note.textpack", + "" + ); + + // Inlining these would leave the base64 in the document as literal + // text, since markdown-it rejects the destination. + expect(result.text).not.toContain("base64"); + expect(result.text).toContain("assets/spec.pdf"); + expect(result.text).toContain("assets/logo.svg"); + }); + }); }); describe("htmlToProsemirror", () => { diff --git a/server/converters/DocumentConverter.ts b/server/converters/DocumentConverter.ts new file mode 100644 index 0000000000..e21ee0a45e --- /dev/null +++ b/server/converters/DocumentConverter.ts @@ -0,0 +1,268 @@ +import type { Node } from "prosemirror-model"; +import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; +import { splitLeadingEmoji } from "@shared/utils/parseTitle"; +import { schema, serializer } from "@server/editor"; +import { FileImportError } from "@server/errors"; +import { trace } from "@server/logging/tracing"; +import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper"; +import { BaseConverter } from "./BaseConverter"; +import { HtmlPreprocessor } from "./HtmlPreprocessor"; + +export interface ConvertResult { + /** The document content as markdown text. */ + text: string; + /** The document content as Prosemirror. */ + doc: Node; + /** The extracted title (from H1 heading if present). */ + title: string; + /** The extracted emoji/icon from start of document. */ + icon?: string; +} + +/** + * Converts incoming files of various formats to structured documents. Each + * format's implementation lives in its own converter alongside this file; this + * class owns the pipeline they feed into — route, parse, lift the title and + * icon, serialize. + */ +@trace() +export class DocumentConverter extends BaseConverter { + /** + * Convert an incoming file to a structured document result. + * + * @param content The content of the file. + * @param fileName The name of the file, including extension. + * @param mimeType The mime type of the file. + * @param options Conversion options. + * @param options.extractTitle Whether a leading H1 heading should be lifted + * out as the document title and removed from the body. Defaults to true; + * set false for sources where the filename is authoritative and the first + * heading must remain part of the content (e.g. Slab). + * @returns The converted document with text, data, title, and icon. + */ + public static async convert( + content: Buffer | string, + fileName: string, + mimeType: string, + options: { extractTitle?: boolean } = {} + ): Promise { + const { extractTitle = true } = options; + let doc: Node; + + // Route to appropriate conversion method + const html = await this.convertToHtml(content, fileName, mimeType); + if (html !== undefined) { + doc = await this.htmlToProsemirror(html); + } else { + const markdown = await this.convertToMarkdown( + content, + fileName, + mimeType + ); + doc = ProsemirrorHelper.toProsemirror(markdown); + } + + // Extract title from first H1 heading + let title = ""; + let icon: string | undefined; + if (extractTitle) { + const headings = ProsemirrorHelper.getHeadings(doc); + if (headings.length > 0 && headings[0].level === 1) { + // An emoji leading the title becomes the document's icon, matching how + // a title is written back out on export. + const { emoji, rest } = splitLeadingEmoji(headings[0].title); + title = rest; + icon = emoji; + doc = ProsemirrorHelper.removeFirstHeading(doc); + } + } + + // Only when the title supplied no icon is the body's leading emoji taken, + // so that a document without a heading can still lead with one. + if (!icon) { + const { emoji, doc: docWithoutEmoji } = + ProsemirrorHelper.extractEmojiFromStart(doc); + icon = emoji; + doc = docWithoutEmoji; + } + + // Serialize to markdown and trim whitespace + const text = serializer.serialize(doc).trim(); + + return { + text, + doc, + title, + icon, + }; + } + + /** + * Convert HTML content directly to a Prosemirror document node. + * + * @param content The HTML content as a string or Buffer. + * @returns A Prosemirror Node representing the document. + */ + public static async htmlToProsemirror( + content: Buffer | string + ): Promise { + content = this.bufferToString(content); + + // Loaded lazily to keep jsdom off the startup path — only HTML imports need it. + const { JSDOM } = await import("jsdom"); + const dom = new JSDOM(content); + const document = dom.window.document; + + // Remove problematic elements before parsing + const elementsToRemove = document.querySelectorAll( + "script, style, title, head, meta, link" + ); + elementsToRemove.forEach((el) => el.remove()); + + // Preprocess the DOM to handle edge cases + HtmlPreprocessor.preprocess(document); + + // Patch global environment for Prosemirror DOMParser + const cleanup = ProsemirrorHelper.patchGlobalEnv(dom.window); + + try { + const domParser = ProsemirrorDOMParser.fromSchema(schema); + return domParser.parse(document.body); + } finally { + cleanup(); + try { + dom.window.close(); + } catch (_err) { + // Best effort, closing the window releases its timers and resources. + } + } + } + + /** + * Attempts to convert content to HTML for formats that support it. + * Returns undefined for formats that should be parsed as markdown directly. + * + * @param content The content of the file. + * @param fileName The name of the file, including extension. + * @param mimeType The mime type of the file. + * @returns HTML string if convertible, undefined otherwise. + */ + private static async convertToHtml( + content: Buffer | string, + fileName: string, + mimeType: string + ): Promise { + const extension = fileName.split(".").pop()?.toLowerCase(); + + // First try to convert based on the mime type + switch (mimeType) { + case "text/html": + return this.bufferToString(content); + case "application/msword": + return (await this.mimeArchive()).confluenceToHtml(content); + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return (await this.docx()).toHtml(content); + // Browsers report MHTML ("Save page as → Webpage, Single File") and .eml + // inconsistently across these three mime types, so the extension is + // used as a tie-breaker between the two archive kinds below. + case "multipart/related": + case "application/x-mimearchive": { + const converter = await this.mimeArchive(); + return extension === "eml" + ? converter.emlToHtml(content) + : converter.mhtmlToHtml(content); + } + case "message/rfc822": { + const converter = await this.mimeArchive(); + return extension === "mhtml" || extension === "mht" + ? converter.mhtmlToHtml(content) + : converter.emlToHtml(content); + } + default: + break; + } + + // Try to convert based on the file extension + switch (extension) { + case "htm": + case "html": + return this.bufferToString(content); + case "docx": + return (await this.docx()).toHtml(content); + case "mhtml": + case "mht": + return (await this.mimeArchive()).mhtmlToHtml(content); + case "eml": + return (await this.mimeArchive()).emlToHtml(content); + default: + return undefined; + } + } + + /** + * Converts content to markdown for text-based formats. + * + * @param content The content of the file. + * @param fileName The name of the file, including extension. + * @param mimeType The mime type of the file. + * @returns Markdown string. + */ + private static async convertToMarkdown( + content: Buffer | string, + fileName: string, + mimeType: string + ): Promise { + let markdown: string; + + switch (mimeType) { + case "text/plain": + case "text/markdown": + markdown = this.bufferToString(content); + break; + case "text/csv": + case "text/tab-separated-values": + return (await this.csv()).toMarkdown(content); + default: { + const extension = fileName.split(".").pop()?.toLowerCase(); + switch (extension) { + case "md": + case "markdown": + case "txt": + markdown = this.bufferToString(content); + break; + case "csv": + case "tsv": + return (await this.csv()).toMarkdown(content); + case "textpack": + return (await this.textPack()).toMarkdown(content); + default: + throw FileImportError(`File type ${mimeType} not supported`); + } + } + } + + // Process frontmatter and convert it to a YAML codeblock + return this.processFrontmatter(markdown); + } + + /** + * Converters are loaded on demand so that the dependencies of a format — + * jsdom, mammoth, mailparser, fast-csv, yauzl — stay off the server startup + * path, and are only paid for by an import that actually needs them. + */ + private static async docx() { + return (await import("./DocxConverter")).DocxConverter; + } + + private static async mimeArchive() { + return (await import("./MimeArchiveConverter")).MimeArchiveConverter; + } + + private static async csv() { + return (await import("./CsvConverter")).CsvConverter; + } + + private static async textPack() { + return (await import("./TextPackConverter")).TextPackConverter; + } +} diff --git a/server/converters/DocxConverter.ts b/server/converters/DocxConverter.ts new file mode 100644 index 0000000000..d125709cd9 --- /dev/null +++ b/server/converters/DocxConverter.ts @@ -0,0 +1,27 @@ +import mammoth from "mammoth"; +import { FileImportError } from "@server/errors"; +import { traceFunction } from "@server/logging/tracing"; +import { BaseConverter } from "./BaseConverter"; + +/** + * Converts Word (.docx) files to HTML. + */ +export class DocxConverter extends BaseConverter { + /** + * Convert a docx file to HTML using mammoth. + * + * @param content The docx file content as a Buffer. + * @returns The HTML representation of the document. + */ + public static async toHtml(content: Buffer | string): Promise { + if (content instanceof Buffer) { + const { value } = await traceFunction({ spanName: "convertToHtml" })( + mammoth.convertToHtml + )({ + buffer: content, + }); + return value; + } + throw FileImportError("Unsupported Word file"); + } +} diff --git a/server/converters/HtmlPreprocessor.ts b/server/converters/HtmlPreprocessor.ts new file mode 100644 index 0000000000..85e53d8fc0 --- /dev/null +++ b/server/converters/HtmlPreprocessor.ts @@ -0,0 +1,162 @@ +/** + * Cleans up an HTML DOM before it is parsed into a Prosemirror document, + * handling the quirks of the tools that produce imported HTML. + */ +export class HtmlPreprocessor { + /** + * Preprocesses HTML DOM before Prosemirror parsing to cleanup + * images and other elements. + * + * @param document The DOM document to preprocess. + */ + public static preprocess(document: Document): void { + // Handle images: filter emoticons, remove Jira icons, apply Confluence sizing + const images = document.querySelectorAll("img"); + images.forEach((img) => { + const className = img.className || ""; + + // Skip emoticon images (they'll be dropped) + if (className.includes("emoticon")) { + img.remove(); + return; + } + + // Remove Jira icon images + if ( + className === "icon" && + img.parentElement?.className.includes("jira-issue-key") + ) { + img.remove(); + return; + } + + // Handle Confluence image sizing: data-width/data-height → width/height + const dataWidth = img.getAttribute("data-width"); + const dataHeight = img.getAttribute("data-height"); + const width = img.getAttribute("width"); + + if (dataWidth && dataHeight && width) { + const ratio = parseInt(dataWidth) / parseInt(width); + const calculatedHeight = Math.round(parseInt(dataHeight) / ratio); + img.setAttribute("height", String(calculatedHeight)); + } + + // Extract dimensions from data URI images that lack width/height + // (e.g. images embedded by mammoth during docx import). + // Only decode a small prefix of the base64 data — headers for all + // supported formats live within the first 64 KB of the file. + if (!img.getAttribute("width") && !img.getAttribute("height")) { + const src = img.getAttribute("src") || ""; + if (src.startsWith("data:") && src.includes(";base64,")) { + const base64Start = src.indexOf(";base64,") + 8; + // 4 base64 chars → 3 bytes; decode at most ~64 KB of image data. + const maxBase64Chars = Math.ceil(65536 / 3) * 4; + const base64Prefix = src.slice( + base64Start, + base64Start + maxBase64Chars + ); + const dimensions = this.getImageDimensions( + Buffer.from(base64Prefix, "base64") + ); + if (dimensions) { + img.setAttribute("width", String(dimensions.width)); + img.setAttribute("height", String(dimensions.height)); + } + } + } + }); + } + /** + * Parse image dimensions from a binary buffer. Supports PNG, JPEG, and GIF. + * + * @param buffer The image data. + * @returns The width and height if parseable, otherwise undefined. + */ + private static getImageDimensions( + buffer: Buffer + ): { width: number; height: number } | undefined { + try { + // PNG: signature + IHDR chunk + if ( + buffer.length >= 24 && + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 + ) { + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; + } + + // GIF: signature + logical screen descriptor + if ( + buffer.length >= 10 && + buffer[0] === 0x47 && + buffer[1] === 0x49 && + buffer[2] === 0x46 + ) { + return { + width: buffer.readUInt16LE(6), + height: buffer.readUInt16LE(8), + }; + } + + // JPEG: scan for SOF marker (cap at 64 KB to bound work) + if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xd8) { + const scanLimit = Math.min(buffer.length, 65536); + let offset = 2; + while (offset + 1 < scanLimit) { + if (buffer[offset] !== 0xff) { + offset++; + continue; + } + const marker = buffer[offset + 1]; + offset += 2; + + // Standalone markers without a payload + if ( + marker === 0x00 || + marker === 0x01 || + (marker >= 0xd0 && marker <= 0xd9) + ) { + continue; + } + + if (offset + 2 > scanLimit) { + break; + } + const segmentLength = buffer.readUInt16BE(offset); + + // SOF markers contain the frame dimensions — check before + // the advance guard since this returns immediately. + if ( + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf) + ) { + if (offset + 7 <= buffer.length) { + return { + height: buffer.readUInt16BE(offset + 3), + width: buffer.readUInt16BE(offset + 5), + }; + } + break; + } + + // Length includes itself and must be >= 2; bail on malformed data. + if (segmentLength < 2 || offset + segmentLength > buffer.length) { + break; + } + + offset += segmentLength; + } + } + } catch { + // Return undefined if parsing fails + } + return undefined; + } +} diff --git a/server/converters/MimeArchiveConverter.ts b/server/converters/MimeArchiveConverter.ts new file mode 100644 index 0000000000..2d5d406ed2 --- /dev/null +++ b/server/converters/MimeArchiveConverter.ts @@ -0,0 +1,201 @@ +import { escape } from "es-toolkit/compat"; +import type { Attachment as MailAttachment, ParsedMail } from "mailparser"; +import { simpleParser } from "mailparser"; +import { FileImportError } from "@server/errors"; +import { BaseConverter } from "./BaseConverter"; + +/** + * Converts MIME archives to HTML. Confluence's "Word" export, MHTML pages + * (Chrome/Edge "Save page as → Webpage, Single File") and .eml messages are + * all, structurally, multi-part email messages, so one parser serves them all. + */ +export class MimeArchiveConverter extends BaseConverter { + /** Maximum number of MIME archive parts that will be inlined as data URIs. */ + private static readonly MIME_ARCHIVE_MAX_INLINE_PARTS = 100; + + /** Maximum size of a single MIME archive part that will be inlined as a data URI. */ + private static readonly MIME_ARCHIVE_MAX_PART_BYTES = 15 * 1024 * 1024; + + /** Maximum combined size of MIME archive parts that will be inlined as data URIs. */ + private static readonly MIME_ARCHIVE_MAX_TOTAL_BYTES = 50 * 1024 * 1024; + + /** Matches a well-formed mime type, e.g. `image/png`. */ + private static readonly MIME_TYPE_REGEX = /^[\w.+-]+\/[\w.+-]+$/; + + /** + * Convert a Confluence Word export to HTML. + * + * @param content The Confluence Word export content. + * @returns The HTML representation of the document. + */ + public static async confluenceToHtml( + content: Buffer | string + ): Promise { + const text = this.bufferToString(content); + + // We're only supporting the output from Confluence here, regular Word documents should call + // into the docxToHtml importer. See: https://jira.atlassian.com/browse/CONFSERVER-38237 + if (!text.includes("Content-Type: multipart/related")) { + throw FileImportError("Unsupported Word file"); + } + + const { html } = await this.parseMimeArchive(text, { + emptyMessage: "Unsupported Word file (No content found)", + }); + + return html; + } + + /** + * Convert an MHTML file (e.g. Chrome/Edge "Save page as → Webpage, Single + * File") to HTML. + * + * @param content The MHTML file content. + * @returns The HTML representation of the document. + */ + public static async mhtmlToHtml(content: Buffer | string): Promise { + const { html } = await this.parseMimeArchive(this.bufferToString(content), { + emptyMessage: "Unsupported MHTML file (No content found)", + }); + + return html; + } + + /** + * Convert an .eml email message to HTML. The `Subject` header, if present, + * is inserted as a leading H1 so it is picked up as the document title by + * the same leading-heading extraction used for every other format. + * + * @param content The .eml file content. + * @returns The HTML representation of the message. + */ + public static async emlToHtml(content: Buffer | string): Promise { + const { html, subject } = await this.parseMimeArchive( + this.bufferToString(content), + { + allowTextFallback: true, + emptyMessage: "Unsupported email file (No content found)", + } + ); + + return subject ? `

${escape(subject)}

\n${html}` : html; + } + + /** + * Parse a MIME archive (`multipart/related`, `message/rfc822`, or similar) + * with mailparser and resolve its HTML body, inlining referenced parts as + * data URIs. + * + * Confluence's "Word" export and MHTML pages (Chrome/Edge "Save page as → + * Webpage, Single File") are both, structurally, multi-part email messages, + * so the same parser and inlining logic serves all of them. + * + * @param content The MIME archive content as a string. + * @param options.allowTextFallback Whether to fall back to the plain text + * body (rendered as HTML) when no HTML part is present. Used for .eml, + * where a text-only email is common; MHTML/Word exports always have HTML. + * @param options.emptyMessage The error message to throw when no usable + * body is found. + * @returns The resolved HTML body and, if present, the message subject. + */ + private static async parseMimeArchive( + content: string, + options: { allowTextFallback?: boolean; emptyMessage: string } + ): Promise<{ html: string; subject?: string }> { + // Confluence "Word" documents, MHTML pages, and .eml files are all just multi-part + // email messages, so we can use mailparser to parse the content. `keepCidLinks` is + // set so we can apply our own bounds when inlining referenced parts below, rather + // than mailparser inlining every matching part unconditionally. + let parsed: ParsedMail; + try { + parsed = await simpleParser(content, { keepCidLinks: true }); + } catch (_err) { + throw FileImportError(options.emptyMessage); + } + + let html = parsed.html || undefined; + if (!html && options.allowTextFallback) { + html = parsed.textAsHtml || undefined; + } + if (!html) { + throw FileImportError(options.emptyMessage); + } + + return { + html: this.inlineMimeArchiveParts(html, parsed.attachments), + subject: parsed.subject, + }; + } + + /** + * Replace references to MIME archive parts within HTML with data URIs, so + * the resulting document is self-contained. Parts are referenced either by + * `Content-Location` (used by MHTML and Confluence exports) or by + * `Content-ID` as a `cid:` URL (used by email). Inlining is bounded so a + * hostile or merely enormous archive cannot blow up memory or the + * resulting document. + * + * @param html The HTML body that may reference archive parts. + * @param attachments The parsed MIME archive parts. + * @returns The HTML with resolvable references replaced by data URIs. + */ + private static inlineMimeArchiveParts( + html: string, + attachments: MailAttachment[] + ): string { + let inlinedParts = 0; + let inlinedBytes = 0; + + for (const attachment of attachments) { + if (inlinedParts >= this.MIME_ARCHIVE_MAX_INLINE_PARTS) { + break; + } + if ( + attachment.content.length > this.MIME_ARCHIVE_MAX_PART_BYTES || + inlinedBytes + attachment.content.length > + this.MIME_ARCHIVE_MAX_TOTAL_BYTES + ) { + continue; + } + + const references = new Set(); + const contentLocation = attachment.headers.get("content-location") as + | string + | undefined; + if (contentLocation) { + references.add(contentLocation); + const basename = contentLocation.split("/").pop(); + if (basename) { + references.add(basename); + } + } + if (attachment.cid) { + references.add(`cid:${attachment.cid}`); + } + + // The content type comes from the archive's own headers, so anything that + // isn't a well-formed mime type is discarded rather than interpolated. + const contentType = this.MIME_TYPE_REGEX.test(attachment.contentType) + ? attachment.contentType + : "application/octet-stream"; + + let replaced = false; + for (const reference of references) { + if (html.includes(reference)) { + const dataUri = `data:${contentType};base64,${attachment.content.toString( + "base64" + )}`; + html = html.split(reference).join(dataUri); + replaced = true; + } + } + + if (replaced) { + inlinedParts++; + inlinedBytes += attachment.content.length; + } + } + + return html; + } +} diff --git a/server/converters/TextPackConverter.ts b/server/converters/TextPackConverter.ts new file mode 100644 index 0000000000..b03511c8a8 --- /dev/null +++ b/server/converters/TextPackConverter.ts @@ -0,0 +1,371 @@ +import mime from "mime-types"; +import { AttachmentPreset } from "@shared/types"; +import { replaceMarkdownLinks } from "@shared/utils/markdown"; +import { DocumentValidation } from "@shared/validations"; +import { FileImportError } from "@server/errors"; +import Logger from "@server/logging/Logger"; +import AttachmentHelper from "@server/models/helpers/AttachmentHelper"; +import TextBundleHelper from "@server/models/helpers/TextBundleHelper"; +import ZipHelper from "@server/utils/ZipHelper"; +import { BaseConverter } from "./BaseConverter"; + +/** Matches a TextBundle's text entry, whose extension the spec leaves open. */ +const TEXTBUNDLE_TEXT_REGEX = /^text\.[^.]+$/; + +/** + * Extensions of a TextBundle text entry that can be read as markdown. The spec + * allows any extension and declares the real content type in info.json, so this + * is only consulted for bundles that omit a type. + */ +const TEXTBUNDLE_TEXT_EXTENSIONS = [ + "markdown", + "markdn", + "mdown", + "mmd", + "md", + "text", + "txt", +]; + +/** UTIs from info.json whose content can be read as markdown or plain text. */ +const TEXTBUNDLE_TEXT_TYPES = [ + "net.daringfireball.markdown", + "public.plain-text", + "public.utf8-plain-text", + "public.text", +]; + +/** + * Media types that can be inlined as a data URI. Markdown-it rejects a `data:` + * destination outside this set, and an asset it rejects would be left in the + * document as literal base64 text, so anything else keeps its original + * reference instead. + */ +const TEXTBUNDLE_EMBEDDABLE_MIME_TYPES = [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]; + +/** Maximum number of entries a TextPack archive may contain. */ +const TEXTBUNDLE_MAX_ENTRIES = 2000; + +/** Maximum size of the bundle's info.json, in bytes. */ +const TEXTBUNDLE_MAX_INFO_SIZE = 1024 * 1024; + +/** Maximum size of the bundle's text file, in bytes. */ +const TEXTBUNDLE_MAX_TEXT_SIZE = DocumentValidation.maxStateLength; + +/** + * Ceiling on the size of a single asset inlined as a data URI, in bytes. The + * configured attachment limit bounds this too, but on its own it is not enough: + * it can be set arbitrarily high, whereas inlining holds the asset in memory + * several times over. + */ +const TEXTBUNDLE_MAX_ASSET_SIZE = 15 * 1024 * 1024; + +/** Maximum combined size of all assets, in bytes. */ +const TEXTBUNDLE_MAX_TOTAL_ASSETS_SIZE = 50 * 1024 * 1024; + +interface TextBundleEntry { + fileName: string; + isDirectory: boolean; +} + +interface TextBundleRoot { + /** Path prefix of the bundle's contents within the zip, "" if not nested in a wrapper folder. */ + root: string; + /** Full path of the text entry within the zip. */ + textPath: string; + /** Full path of the bundle's info.json, if it has one. */ + infoPath?: string; +} + +/** + * Converts a TextPack — the zipped form of a TextBundle — to markdown. + * + * @see https://textbundle.org/spec/ + */ +export class TextPackConverter extends BaseConverter { + /** + * Convert a TextPack (a zipped TextBundle) to markdown, embedding referenced + * assets as base64 data URIs so they survive as ordinary markdown images + * until {@link ProsemirrorHelper.replaceImagesWithAttachments} turns them + * into real attachments. Assets of a type that cannot be inlined keep their + * original reference. + * + * @param content The TextPack file content as a Buffer. + * @returns The bundle's text content as markdown, with assets embedded. + * @throws {FileImportError} if the archive is malformed, oversized, holds no + * text file, or declares a format that cannot be read as markdown. + */ + public static async toMarkdown(content: Buffer | string): Promise { + if (!(content instanceof Buffer)) { + throw FileImportError("Unsupported TextPack file"); + } + + const entries: TextBundleEntry[] = []; + + await ZipHelper.walk(content, (entry) => { + entries.push({ + fileName: entry.fileName, + isDirectory: entry.isDirectory, + }); + if (entries.length > TEXTBUNDLE_MAX_ENTRIES) { + throw FileImportError("TextPack file contains too many entries"); + } + }); + + const bundle = this.findTextBundleRoot(entries); + if (!bundle) { + throw FileImportError( + "TextPack file does not contain a recognizable text file" + ); + } + + const rootPrefix = bundle.root ? `${bundle.root}/` : ""; + // Compared case-insensitively: the spec only recommends lowercase names. + const assetPrefix = + `${rootPrefix}${TextBundleHelper.assetsDirectory}/`.toLowerCase(); + // Keyed on the lowercased path for the same reason; TextBundle is designed + // for case-insensitive filesystems, so two assets differing only in case + // cannot coexist in a valid bundle. + const assets = new Map(); + // Each asset becomes an attachment downstream, so anything the attachment + // pipeline would reject is not worth inlining as a data URI first. + const maxAssetSize = Math.min( + TEXTBUNDLE_MAX_ASSET_SIZE, + AttachmentHelper.presetToMaxUploadSize( + AttachmentPreset.DocumentAttachment + ) + ); + let markdown: string | undefined; + let info: string | undefined; + let totalAssetBytes = 0; + + await ZipHelper.walk(content, async (entry) => { + if (entry.isDirectory) { + return; + } + + if (entry.fileName === bundle.textPath) { + const buffer = await entry.readBuffer(TEXTBUNDLE_MAX_TEXT_SIZE); + markdown = buffer.toString("utf8"); + return; + } + + if (entry.fileName === bundle.infoPath) { + const buffer = await entry.readBuffer(TEXTBUNDLE_MAX_INFO_SIZE); + info = buffer.toString("utf8"); + return; + } + + if (!entry.fileName.toLowerCase().startsWith(assetPrefix)) { + return; + } + + const relativePath = entry.fileName.slice(rootPrefix.length); + const assetMimeType = + mime.lookup(relativePath) || "application/octet-stream"; + + if (!TEXTBUNDLE_EMBEDDABLE_MIME_TYPES.includes(assetMimeType)) { + Logger.info( + "utils", + `Skipping TextPack asset of unembeddable type ${assetMimeType}` + ); + return; + } + + const buffer = await entry.readBuffer(maxAssetSize); + + // Checked against the actual decompressed size, not the archive's + // (attacker-controlled) declared size, so this also bounds the total + // work done for a bundle of many small-but-lying entries. + totalAssetBytes += buffer.length; + if (totalAssetBytes > TEXTBUNDLE_MAX_TOTAL_ASSETS_SIZE) { + throw FileImportError( + "TextPack file assets exceed the maximum combined size" + ); + } + + assets.set( + relativePath.toLowerCase(), + `data:${assetMimeType};base64,${buffer.toString("base64")}` + ); + }); + + if (markdown === undefined) { + throw FileImportError( + "TextPack file does not contain a recognizable text file" + ); + } + + this.assertTextBundleIsText(bundle.textPath, info); + + markdown = this.embedTextBundleAssets(markdown, assets); + + return this.processFrontmatter(markdown); + } + + /** + * Locate a TextBundle's text file within a zip's entries, tolerant of the + * bundle either being wrapped in a `*.textbundle` directory or zipped flat. + * The spec leaves the text entry's extension open, so any `text.*` matches. + * + * @param entries The zip's entries. + * @returns The bundle's root path prefix, text entry path and info.json path, + * or null if no recognizable text file was found. + */ + private static findTextBundleRoot( + entries: TextBundleEntry[] + ): TextBundleRoot | null { + const isVisible = (segments: string[]) => + !segments.some( + (segment) => segment === "__MACOSX" || segment.startsWith(".") + ); + + let best: + | (TextBundleRoot & { depth: number; knownExtension: boolean }) + | null = null; + let candidates = 0; + + for (const entry of entries) { + if (entry.isDirectory) { + continue; + } + + const segments = entry.fileName.split("/").filter(Boolean); + if (!isVisible(segments)) { + continue; + } + + const name = segments[segments.length - 1].toLowerCase(); + if (!TEXTBUNDLE_TEXT_REGEX.test(name)) { + continue; + } + + candidates++; + + // Prefer the shallowest bundle, and among equals the one whose extension + // we already know how to read, so a `text.md` beside a `text.fountain` + // wins without needing info.json. + const depth = segments.length; + const knownExtension = TEXTBUNDLE_TEXT_EXTENSIONS.includes( + name.slice("text.".length) + ); + + if ( + !best || + depth < best.depth || + (depth === best.depth && knownExtension && !best.knownExtension) + ) { + best = { + root: segments.slice(0, -1).join("/"), + textPath: entry.fileName, + depth, + knownExtension, + }; + } + } + + if (!best) { + return null; + } + + if (candidates > 1) { + Logger.warn( + "TextPack contains more than one text file, importing the first bundle only", + { candidates } + ); + } + + const rootPrefix = best.root ? `${best.root}/` : ""; + const infoPath = entries.find( + (entry) => + !entry.isDirectory && + entry.fileName.toLowerCase() === + `${rootPrefix}${TextBundleHelper.infoFileName}`.toLowerCase() + )?.fileName; + + return { root: best.root, textPath: best.textPath, infoPath }; + } + + /** + * Assert that a TextBundle holds markdown or plain text rather than another + * format such as RTF. The bundle's declared `type` is authoritative; when it + * is absent the text entry's extension is used instead. + * + * @param textPath Path of the bundle's text entry within the zip. + * @param info Raw contents of the bundle's info.json, if it has one. + * @throws {FileImportError} if the bundle declares a format we cannot read. + */ + private static assertTextBundleIsText( + textPath: string, + info: string | undefined + ): void { + let type: string | undefined; + + if (info) { + try { + const parsed: unknown = JSON.parse(info); + if ( + parsed && + typeof parsed === "object" && + "type" in parsed && + typeof parsed.type === "string" + ) { + type = parsed.type.toLowerCase(); + } + } catch (_err) { + // A malformed info.json falls back to the extension check below. + } + } + + if (type) { + if ( + !TEXTBUNDLE_TEXT_TYPES.includes(type) && + !type.includes("markdown") && + !type.includes("plain-text") + ) { + throw FileImportError( + `TextPack file contains ${type} content, which cannot be imported` + ); + } + return; + } + + const extension = textPath.split(".").pop()?.toLowerCase() ?? ""; + if (!TEXTBUNDLE_TEXT_EXTENSIONS.includes(extension)) { + throw FileImportError( + `TextPack file contains a .${extension} text file, which cannot be imported` + ); + } + } + + /** + * Replace TextBundle asset references (e.g. `![](assets/image.png)`) in + * markdown with their base64 data URI, resolving relative and + * percent-encoded links. + * + * @param markdown The bundle's markdown text. + * @param assets Map of lowercased asset path (relative to the bundle root) + * to data URI. + * @returns The markdown with recognized asset links replaced. + */ + private static embedTextBundleAssets( + markdown: string, + assets: Map + ): string { + return replaceMarkdownLinks(markdown, (href) => { + let key = href.replace(/^\.\//, ""); + try { + key = decodeURIComponent(key); + } catch { + // Leave as-is if not validly percent-encoded. + } + + return assets.get(key.toLowerCase()); + }); + } +} diff --git a/server/models/helpers/ProseMirrorHelper.test.ts b/server/models/helpers/ProseMirrorHelper.test.ts index ce09060463..d3c6b2fae2 100644 --- a/server/models/helpers/ProseMirrorHelper.test.ts +++ b/server/models/helpers/ProseMirrorHelper.test.ts @@ -8,6 +8,7 @@ import { MentionType } from "@shared/types"; import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import { createContext } from "@server/context"; import { schema } from "@server/editor"; +import { Attachment } from "@server/models"; import { buildProseMirrorDoc, buildUser } from "@server/test/factories"; import type { MentionAttrs } from "./ProsemirrorHelper"; import { ProsemirrorHelper } from "./ProsemirrorHelper"; @@ -1332,6 +1333,78 @@ describe("ProsemirrorHelper", () => { expect(result.toJSON()).toEqual(doc.toJSON()); }); + + it("should turn a link pointing at a data URI into an attachment", async () => { + const user = await buildUser(); + const ctx = createContext({ user }); + + const doc = buildProseMirrorDoc([ + { + type: "paragraph", + content: [ + { + type: "text", + text: "the spec", + marks: [ + { + type: "link", + attrs: { + href: "data:application/pdf;base64,JVBERi0xLjQK", + }, + }, + ], + }, + ], + }, + ]); + + const result = await ProsemirrorHelper.replaceImagesWithAttachments( + ctx, + doc, + user + ); + + const link = result.content + .child(0) + .content.child(0) + .marks.find((mark) => mark.type.name === "link"); + + expect(link?.attrs.href).toMatch(/^\/api\/attachments\.redirect\?id=/); + expect(await Attachment.count({ where: { teamId: user.teamId } })).toBe( + 1 + ); + }); + + it("should leave an ordinary external link untouched", async () => { + const user = await buildUser(); + const ctx = createContext({ user }); + + const doc = buildProseMirrorDoc([ + { + type: "paragraph", + content: [ + { + type: "text", + text: "example", + marks: [ + { type: "link", attrs: { href: "https://example.com/page" } }, + ], + }, + ], + }, + ]); + + const result = await ProsemirrorHelper.replaceImagesWithAttachments( + ctx, + doc, + user + ); + + expect(result.toJSON()).toEqual(doc.toJSON()); + expect(await Attachment.count({ where: { teamId: user.teamId } })).toBe( + 0 + ); + }); }); describe("#applyCommentMarkByText", () => { diff --git a/server/models/helpers/ProsemirrorHelper.tsx b/server/models/helpers/ProsemirrorHelper.tsx index d28c7c9aaa..e3b9d748fc 100644 --- a/server/models/helpers/ProsemirrorHelper.tsx +++ b/server/models/helpers/ProsemirrorHelper.tsx @@ -975,12 +975,15 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper { /** * Replaces remote and base64 encoded images in the given Prosemirror node - * with attachment urls and uploads the images to the storage provider. + * with attachment urls and uploads the images to the storage provider. Links + * pointing at a data URI are uploaded too, so that files which are not + * images — a PDF bundled alongside a document, for example — do not remain + * embedded in the document as base64. * * @param ctx The API context. * @param doc The Prosemirror node to process. * @param user The user context. - * @returns A new Prosemirror node with images replaced. + * @returns A new Prosemirror node with images and embedded files replaced. */ static async replaceImagesWithAttachments( ctx: APIContext, @@ -991,21 +994,41 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper { const videos = ProsemirrorHelper.getVideos(doc); const nodes = [...images, ...videos]; - if (!nodes.length) { + // Only data URIs are collected from links: an ordinary external link is a + // reference to a page, not a file the document should take a copy of. + const links: { href: string; name: string }[] = []; + doc.descendants((node) => { + const link = node.marks.find((mark) => mark.type.name === "link"); + const href = String(link?.attrs.href ?? ""); + if (href.startsWith("data:")) { + links.push({ href, name: node.textContent || "file" }); + } + return true; + }); + + if (!nodes.length && !links.length) { return doc; } + const sources = [ + ...nodes.map((node) => ({ + href: String(node.attrs.src ?? ""), + name: String(node.attrs.alt ?? node.type.name), + })), + ...links, + ]; + const timeoutPerImage = Math.floor( - Math.min(env.REQUEST_TIMEOUT / nodes.length, 10000) + Math.min(env.REQUEST_TIMEOUT / sources.length, 10000) ); const urlToAttachment: Map = new Map(); - const chunks = chunk(nodes, 10); + const chunks = chunk(sources, 10); - for (const nodeChunk of chunks) { + for (const sourceChunk of chunks) { await Promise.all( - nodeChunk.map(async (node) => { - const src = String(node.attrs.src ?? ""); + sourceChunk.map(async (source) => { + const src = source.href; // Skip invalid URLs try { @@ -1026,7 +1049,7 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper { try { const attachment = await attachmentCreator({ - name: String(node.attrs.alt ?? node.type.name), + name: source.name, url: src, preset: AttachmentPreset.DocumentAttachment, user, @@ -1049,7 +1072,8 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper { ); } - // Transform the document to replace image/video src attributes + // Transform the document to replace image/video src attributes and the + // href of any link that was uploaded above. const transformFragment = (fragment: Fragment): Fragment => { const transformedNodes: Node[] = []; @@ -1065,10 +1089,27 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper { } else { transformedNodes.push(node); } - } else if (node.content.size > 0) { - transformedNodes.push(node.copy(transformFragment(node.content))); } else { - transformedNodes.push(node); + const marks = node.marks.map((mark) => { + const attachment = + mark.type.name === "link" + ? urlToAttachment.get(String(mark.attrs.href ?? "")) + : undefined; + + return attachment + ? mark.type.create({ + ...mark.attrs, + href: attachment.redirectUrl, + }) + : mark; + }); + + const content = + node.content.size > 0 ? transformFragment(node.content) : undefined; + + transformedNodes.push( + (content ? node.copy(content) : node).mark(marks) + ); } }); diff --git a/server/models/helpers/TextBundleHelper.ts b/server/models/helpers/TextBundleHelper.ts new file mode 100644 index 0000000000..9d4ea97088 --- /dev/null +++ b/server/models/helpers/TextBundleHelper.ts @@ -0,0 +1,74 @@ +import path from "node:path"; +import env from "@server/env"; +import type Document from "@server/models/Document"; +import { serializeFilename } from "@server/utils/fs"; + +/** + * Helpers for writing documents in the TextBundle format, a directory holding + * a document's text alongside the files it references. + * + * @see https://textbundle.org/spec/ + */ +export default class TextBundleHelper { + /** + * Name of a bundle's text entry. Markdown is the format's default type, so + * the extension is the conventional one rather than a choice. + */ + public static readonly textFileName = "text.markdown"; + + /** Name of a bundle's metadata entry. */ + public static readonly infoFileName = "info.json"; + + /** Directory a bundle's referenced files live in. */ + public static readonly assetsDirectory = "assets"; + + /** Extension of a bundle directory, without a leading dot. */ + public static readonly bundleExtension = "textbundle"; + + /** Extension of a zipped bundle, without a leading dot. */ + public static readonly packExtension = "textpack"; + + /** + * Builds the metadata entry that identifies a directory as a TextBundle. + * + * @param document The document the bundle was built from. + * @returns The contents of the bundle's info.json. + */ + public static info(document: Document): string { + return JSON.stringify( + { + version: 2, + type: "net.daringfireball.markdown", + transient: false, + creatorIdentifier: "com.getoutline.outline", + creatorURL: env.URL, + sourceURL: `${env.URL}${document.url}`, + }, + null, + 2 + ); + } + + /** + * Resolves a name for an asset that is safe as a path component and unique + * within a single bundle, since two attachments on one document may share an + * original file name. + * + * @param name The attachment's original file name. + * @param used Names already taken within this bundle, added to in place. + * @returns The path of the asset relative to the bundle's text file. + */ + public static assetPath(name: string, used: Set): string { + const serialized = serializeFilename(path.basename(name)); + const { name: base, ext } = path.parse(serialized); + + let candidate = base ? serialized : "file"; + let i = 0; + while (used.has(candidate)) { + candidate = `${base || "file"} (${++i})${ext}`; + } + + used.add(candidate); + return path.join(this.assetsDirectory, candidate); + } +} diff --git a/server/queues/processors/FileOperationCreatedProcessor.ts b/server/queues/processors/FileOperationCreatedProcessor.ts index 0ef5161dbb..598b352b7e 100644 --- a/server/queues/processors/FileOperationCreatedProcessor.ts +++ b/server/queues/processors/FileOperationCreatedProcessor.ts @@ -4,6 +4,7 @@ import type { Event as TEvent, FileOperationEvent } from "@server/types"; import ExportHTMLZipTask from "../tasks/ExportHTMLZipTask"; import ExportJSONTask from "../tasks/ExportJSONTask"; import ExportMarkdownZipTask from "../tasks/ExportMarkdownZipTask"; +import ExportTextBundleZipTask from "../tasks/ExportTextBundleZipTask"; import BaseProcessor from "./BaseProcessor"; export default class FileOperationCreatedProcessor extends BaseProcessor { @@ -32,6 +33,11 @@ export default class FileOperationCreatedProcessor extends BaseProcessor { fileOperationId: event.modelId, }); break; + case FileOperationFormat.TextBundleZip: + await new ExportTextBundleZipTask().schedule({ + fileOperationId: event.modelId, + }); + break; case FileOperationFormat.JSON: await new ExportJSONTask().schedule({ fileOperationId: event.modelId, diff --git a/server/queues/tasks/ExportDocumentTreeTask.ts b/server/queues/tasks/ExportDocumentTreeTask.ts index 9f6867904d..2b111d931f 100644 --- a/server/queues/tasks/ExportDocumentTreeTask.ts +++ b/server/queues/tasks/ExportDocumentTreeTask.ts @@ -11,17 +11,37 @@ import Document from "@server/models/Document"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import HTMLHelper from "@server/models/helpers/HTMLHelper"; import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper"; +import TextBundleHelper from "@server/models/helpers/TextBundleHelper"; import ZipHelper from "@server/utils/ZipHelper"; import { serializeFilename } from "@server/utils/fs"; import ExportTask from "./ExportTask"; export default abstract class ExportDocumentTreeTask extends ExportTask { + /** + * The extension given to each document's entry in the archive. For TextBundle + * this names a directory rather than a file. + * + * @param format The format being exported. + * @returns The extension, without a leading dot. + */ + private static extensionForFormat(format: FileOperationFormat): string { + switch (format) { + case FileOperationFormat.HTMLZip: + return "html"; + case FileOperationFormat.TextBundleZip: + return TextBundleHelper.bundleExtension; + default: + return "md"; + } + } + /** * Exports the document tree to the given zip instance. * * @param zip The yazl ZipFile to add files to * @param documentId The document ID to export - * @param pathInZip The path in the zip to add the document to + * @param pathInZip The path in the zip to add the document to. For TextBundle + * this is the bundle directory rather than a file. * @param format The format to export in */ protected async processDocument({ @@ -50,6 +70,15 @@ export default abstract class ExportDocumentTreeTask extends ExportTask { ? await DocumentHelper.toHTML(document, { centered: true }) : await DocumentHelper.toMarkdown(document); + const isTextBundle = format === FileOperationFormat.TextBundleZip; + + // A TextBundle is a directory, so its text and assets sit inside the path + // reserved for the document rather than beside it. + const textPathInZip = isTextBundle + ? path.join(pathInZip, TextBundleHelper.textFileName) + : pathInZip; + const usedAssetNames = new Set(); + const attachmentIds = includeAttachments ? ProsemirrorHelper.parseAttachmentIds( DocumentHelper.toProsemirror(document) @@ -123,15 +152,23 @@ export default abstract class ExportDocumentTreeTask extends ExportTask { } } + // TextBundle requires assets to live in the bundle's own assets folder, + // referenced relative to the text file, rather than at their storage key. + const reference = isTextBundle + ? TextBundleHelper.assetPath(attachment.name, usedAssetNames) + : attachment.key; + this.addAttachmentToArchive( zip, attachment, - path.join(dir, attachment.key) + isTextBundle + ? path.join(pathInZip, reference) + : path.join(dir, reference) ); text = text.replace( new RegExp(escapeRegExp(attachment.redirectUrl), "g"), - encodeURI(attachment.key) + encodeURI(reference) ); } @@ -144,7 +181,7 @@ export default abstract class ExportDocumentTreeTask extends ExportTask { const matchedDocPath = pathMap.get(matchedLink); if (matchedDocPath) { - const relativePath = path.relative(pathInZip, matchedDocPath); + const relativePath = path.relative(textPathInZip, matchedDocPath); if (relativePath.startsWith(".")) { text = text.replace( matchedLink, @@ -154,8 +191,16 @@ export default abstract class ExportDocumentTreeTask extends ExportTask { } }); + if (isTextBundle) { + zip.addBuffer( + Buffer.from(TextBundleHelper.info(document)), + path.join(pathInZip, TextBundleHelper.infoFileName), + { mtime: document.updatedAt } + ); + } + // Finally, add the document to the zip file - zip.addBuffer(Buffer.from(text), pathInZip, { + zip.addBuffer(Buffer.from(text), textPathInZip, { mtime: document.updatedAt, fileComment: JSON.stringify({ createdAt: document.createdAt, @@ -204,11 +249,13 @@ export default abstract class ExportDocumentTreeTask extends ExportTask { }) { const pathMap = new Map(); - const extension = format === FileOperationFormat.HTMLZip ? "html" : "md"; const rootFolderName = serializeFilename(document.titleWithDefault); // entry for root document - pathMap.set(document.path, `${rootFolderName}.${extension}`); + pathMap.set( + document.path, + `${rootFolderName}.${ExportDocumentTreeTask.extensionForFormat(format)}` + ); this.addDocumentTreeToPathMap( pathMap, @@ -313,7 +360,7 @@ export default abstract class ExportDocumentTreeTask extends ExportTask { ) { for (const node of nodes) { const title = serializeFilename(node.title) || "Untitled"; - const extension = format === FileOperationFormat.HTMLZip ? "html" : "md"; + const extension = ExportDocumentTreeTask.extensionForFormat(format); // Ensure the document is given a unique path in zip, even if it has // the same title as another document in the same collection. diff --git a/server/queues/tasks/ExportTextBundleZipTask.test.ts b/server/queues/tasks/ExportTextBundleZipTask.test.ts new file mode 100644 index 0000000000..139709ce3f --- /dev/null +++ b/server/queues/tasks/ExportTextBundleZipTask.test.ts @@ -0,0 +1,253 @@ +import { Readable } from "node:stream"; +import fs from "fs-extra"; +import { vi } from "vitest"; +import FileStorage from "@server/storage/files"; +import { DocumentConverter } from "@server/converters/DocumentConverter"; +import ZipHelper from "@server/utils/ZipHelper"; +import { + buildAttachment, + buildCollection, + buildDocument, + buildDocumentWithAttachment, + buildFileOperation, + buildTeam, + buildUser, +} from "@server/test/factories"; +import { buildZip } from "@server/test/support"; +import ExportTextBundleZipTask from "./ExportTextBundleZipTask"; + +/** A 1x1 transparent PNG, used as attachment contents in the round trip. */ +const PNG_PIXEL = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64" +); + +describe("ExportTextBundleZipTask", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should write each document as a TextBundle directory", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + teamId: team.id, + createdById: user.id, + }); + const parent = await buildDocument({ + teamId: team.id, + userId: user.id, + collectionId: collection.id, + title: "Parent", + }); + await collection.addDocumentToStructure(parent); + const child = await buildDocument({ + teamId: team.id, + userId: user.id, + collectionId: collection.id, + parentDocumentId: parent.id, + title: "Child", + }); + await collection.addDocumentToStructure(child); + + const fileOperation = await buildFileOperation({ + teamId: team.id, + userId: user.id, + }); + + const task = new ExportTextBundleZipTask(); + const filePath = await task.exportCollections([collection], fileOperation); + + try { + const contents = await readZipContents(filePath); + + // Nested documents keep the folder layout the markdown export uses, with + // each document itself becoming a bundle directory. + expect(Object.keys(contents).sort()).toEqual([ + `${collection.name}/Parent.textbundle/info.json`, + `${collection.name}/Parent.textbundle/text.markdown`, + `${collection.name}/Parent/Child.textbundle/info.json`, + `${collection.name}/Parent/Child.textbundle/text.markdown`, + ]); + } finally { + await fs.remove(filePath); + } + }); + + it("should declare the bundle as markdown in info.json", async () => { + const { collection, document, fileOperation } = + await buildDocumentWithAttachment(); + + vi.spyOn(FileStorage, "getFileStream").mockResolvedValue( + Readable.from(["bytes"]) + ); + + const task = new ExportTextBundleZipTask(); + const filePath = await task.exportCollections([collection], fileOperation); + + try { + const contents = await readZipContents(filePath); + const info = JSON.parse( + contents[`${collection.name}/${document.title}.textbundle/info.json`] + ); + + expect(info.version).toBe(2); + expect(info.type).toBe("net.daringfireball.markdown"); + expect(info.transient).toBe(false); + } finally { + await fs.remove(filePath); + } + }); + + it("should write attachments into the bundle's assets folder", async () => { + const { collection, document, attachment, fileOperation } = + await buildDocumentWithAttachment(); + + const getFileStream = vi + .spyOn(FileStorage, "getFileStream") + .mockResolvedValue(Readable.from(["image-", "bytes"])); + + const task = new ExportTextBundleZipTask(); + const filePath = await task.exportCollections([collection], fileOperation); + + try { + const contents = await readZipContents(filePath); + const bundle = `${collection.name}/${document.title}.textbundle`; + + expect(contents[`${bundle}/assets/${attachment.name}`]).toBe( + "image-bytes" + ); + // The spec requires assets to be referenced relative to the text file. + expect(contents[`${bundle}/text.markdown`]).toContain( + `assets/${attachment.name}` + ); + expect(contents[`${bundle}/text.markdown`]).not.toContain( + attachment.redirectUrl + ); + expect(getFileStream).toHaveBeenCalledWith(attachment.key); + } finally { + await fs.remove(filePath); + } + }); + + it("should link between documents relative to the text file", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + teamId: team.id, + createdById: user.id, + }); + const target = await buildDocument({ + teamId: team.id, + userId: user.id, + collectionId: collection.id, + title: "Target", + }); + await collection.addDocumentToStructure(target); + const source = await buildDocument({ + teamId: team.id, + userId: user.id, + collectionId: collection.id, + title: "Source", + text: `See [Target](${target.url})`, + }); + await collection.addDocumentToStructure(source); + + const fileOperation = await buildFileOperation({ + teamId: team.id, + userId: user.id, + }); + + const task = new ExportTextBundleZipTask(); + const filePath = await task.exportCollections([collection], fileOperation); + + try { + const contents = await readZipContents(filePath); + const text = + contents[`${collection.name}/Source.textbundle/text.markdown`]; + + // The text file sits one level inside the bundle, so a sibling bundle is + // reached by stepping out of it first. + expect(text).toContain("../Target.textbundle"); + } finally { + await fs.remove(filePath); + } + }); + + it("should produce a bundle the TextPack importer can read back", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + teamId: team.id, + createdById: user.id, + }); + const attachment = await buildAttachment( + { teamId: team.id, userId: user.id, contentType: "image/png" }, + "photo.png" + ); + const document = await buildDocument({ + teamId: team.id, + userId: user.id, + collectionId: collection.id, + title: "Round Trip", + icon: "🚀", + text: `![image](${attachment.redirectUrl})`, + }); + await collection.addDocumentToStructure(document); + const fileOperation = await buildFileOperation({ + teamId: team.id, + userId: user.id, + }); + + vi.spyOn(FileStorage, "getFileStream").mockResolvedValue( + Readable.from([PNG_PIXEL]) + ); + + const task = new ExportTextBundleZipTask(); + const filePath = await task.exportCollections([collection], fileOperation); + + try { + // Repackage the exported bundle on its own, which is what a TextPack is. + const bundle = `${collection.name}/${document.title}.textbundle/`; + const files: Record = {}; + await ZipHelper.walk(filePath, async (entry) => { + if (!entry.isDirectory && entry.fileName.startsWith(bundle)) { + files[entry.fileName.slice(bundle.length)] = await entry.readBuffer( + 1024 * 1024 + ); + } + }); + + const result = await DocumentConverter.convert( + await buildZip(files), + `${document.title}.textpack`, + "application/octet-stream" + ); + + expect(result.title).toEqual(document.title); + // The icon is written into the title on export, so it has to come back + // out of it rather than staying glued to the title. + expect(result.icon).toEqual("🚀"); + // The asset resolves through the round trip rather than being left as a + // dangling relative reference. + expect(result.text).toContain("data:image/png;base64,"); + expect(result.text).not.toContain(`assets/${attachment.name}`); + } finally { + await fs.remove(filePath); + } + }); +}); + +async function readZipContents( + filePath: string +): Promise> { + const contents: Record = {}; + await ZipHelper.walk(filePath, async (entry) => { + if (!entry.isDirectory) { + contents[entry.fileName] = (await entry.readBuffer(1024 * 1024)).toString( + "utf8" + ); + } + }); + return contents; +} diff --git a/server/queues/tasks/ExportTextBundleZipTask.ts b/server/queues/tasks/ExportTextBundleZipTask.ts new file mode 100644 index 0000000000..afff6661c2 --- /dev/null +++ b/server/queues/tasks/ExportTextBundleZipTask.ts @@ -0,0 +1,31 @@ +import type { NavigationNode } from "@shared/types"; +import { FileOperationFormat } from "@shared/types"; +import type { Collection, FileOperation } from "@server/models"; +import type { Document } from "@server/models"; +import ExportDocumentTreeTask from "./ExportDocumentTreeTask"; + +export default class ExportTextBundleZipTask extends ExportDocumentTreeTask { + public async exportCollections( + collections: Collection[], + fileOperation: FileOperation + ) { + return await this.addCollectionsToArchive( + collections, + FileOperationFormat.TextBundleZip, + fileOperation.options?.includeAttachments + ); + } + + public async exportDocument( + document: Document, + documentStructure: NavigationNode[], + includeAttachments: boolean + ): Promise { + return await this.addDocumentToArchive({ + document, + documentStructure, + format: FileOperationFormat.TextBundleZip, + includeAttachments, + }); + } +} diff --git a/server/queues/tasks/MarkdownAPIImportTask.test.ts b/server/queues/tasks/MarkdownAPIImportTask.test.ts index ad832dc50b..a57fa6c8ae 100644 --- a/server/queues/tasks/MarkdownAPIImportTask.test.ts +++ b/server/queues/tasks/MarkdownAPIImportTask.test.ts @@ -125,4 +125,22 @@ describe("rewriteInternalLinks", () => { ); expect(out).toBe("see [other](<>)"); }); + + it("rewrites an angle bracketed link to a document with spaces", () => { + const out = rewriteInternalLinks( + "see [other](<./My Doc.md>)", + "Collection/parent.md", + { "Collection/My Doc.md": "doc-4" } + ); + expect(out).toBe("see [other](<>)"); + }); + + it("rewrites a link carrying a title, keeping the title", () => { + const out = rewriteInternalLinks( + 'see [other](./other.md "The other one")', + "Collection/parent.md", + { "Collection/other.md": "doc-5" } + ); + expect(out).toBe('see [other](<> "The other one")'); + }); }); diff --git a/server/queues/tasks/MarkdownAPIImportTask.ts b/server/queues/tasks/MarkdownAPIImportTask.ts index 83815051f2..5f3864583f 100644 --- a/server/queues/tasks/MarkdownAPIImportTask.ts +++ b/server/queues/tasks/MarkdownAPIImportTask.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { escapeRegExp, truncate } from "es-toolkit/compat"; import mime from "mime-types"; import { UniqueConstraintError } from "sequelize"; +import { replaceMarkdownLinks } from "@shared/utils/markdown"; import { CollectionValidation, DocumentValidation } from "@shared/validations"; import type { ImportTaskInput, @@ -28,7 +29,7 @@ import type { ZipTreeNode } from "@server/utils/ZipHelper"; import ZipHelper from "@server/utils/ZipHelper"; import type { ProcessOutput } from "./APIImportTask"; import APIImportTask from "./APIImportTask"; -import { DocumentConverter } from "@server/utils/DocumentConverter"; +import { DocumentConverter } from "@server/converters/DocumentConverter"; type Markdown = IntegrationService.Markdown; @@ -133,22 +134,18 @@ export function rewriteInternalLinks( docMap: Record ): string { const basePath = path.dirname(documentPath); - const internalLinks = [...markdown.matchAll(/\[[^\]]+\]\(([^)]+\.md)\)/g)]; - let text = markdown; - for (const match of internalLinks) { - const referredDocPath = match[1]; - const normalizedDocPath = decodeURI( - path.normalize(`${basePath}/${referredDocPath}`) - ); + return replaceMarkdownLinks(markdown, (href) => { + let normalizedDocPath: string; + try { + normalizedDocPath = decodeURI(path.normalize(`${basePath}/${href}`)); + } catch { + return undefined; + } const referredDocId = docMap[normalizedDocPath]; - if (referredDocId) { - text = text.replace(referredDocPath, `<<${referredDocId}>>`); - } - } - - return text; + return referredDocId ? `<<${referredDocId}>>` : undefined; + }); } export default class MarkdownAPIImportTask extends APIImportTask { diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index 2bf1d128cf..c283b7bbbc 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -6,6 +6,7 @@ import FormData from "form-data"; import { CollectionPermission, DocumentPermission, + ExportContentType, StatusFilter, UserRole, } from "@shared/types"; @@ -736,6 +737,78 @@ describe("#documents.export", () => { expect(entries["export-test.md"]).not.toContain(attachment.redirectUrl); }); + it("should stream a textpack when TextBundle is requested", async () => { + const user = await buildUser(); + const attachment = await buildAttachment( + { teamId: user.teamId, userId: user.id, contentType: "image/png" }, + "photo.png" + ); + const document = await buildDocument({ + title: "Export Test", + userId: user.id, + teamId: user.teamId, + text: `![image](${attachment.redirectUrl})`, + }); + vi.spyOn(FileStorage, "getFileBuffer").mockResolvedValue( + Buffer.from("image-data") + ); + + const res = await server.post("/api/documents.export", user, { + body: { + id: document.id, + }, + headers: { + accept: ExportContentType.TextBundle, + }, + }); + + expect(res.status).toEqual(200); + expect(res.headers.get("content-disposition")).toContain( + `filename="export-test.textpack"` + ); + + const bundle = "export-test.textbundle"; + const entries = await readZipResponse(res); + expect(Object.keys(entries).sort()).toEqual([ + `${bundle}/assets/photo.png`, + `${bundle}/info.json`, + `${bundle}/text.markdown`, + ]); + expect(entries[`${bundle}/assets/photo.png`]).toEqual("image-data"); + expect(entries[`${bundle}/text.markdown`]).toContain("assets/photo.png"); + expect(JSON.parse(entries[`${bundle}/info.json`]).type).toEqual( + "net.daringfireball.markdown" + ); + }); + + it("should stream a textpack when TextBundle is requested for a document with no attachments", async () => { + const user = await buildUser(); + const document = await buildDocument({ + title: "Plain Export", + userId: user.id, + teamId: user.teamId, + }); + + const res = await server.post("/api/documents.export", user, { + body: { + id: document.id, + }, + headers: { + accept: ExportContentType.TextBundle, + }, + }); + + expect(res.status).toEqual(200); + + // A bundle is a directory, so there is no self-contained single file form + // to fall back to when nothing is referenced. + const entries = await readZipResponse(res); + expect(Object.keys(entries).sort()).toEqual([ + "plain-export.textbundle/info.json", + "plain-export.textbundle/text.markdown", + ]); + }); + it("should require authorization without token", async () => { const document = await buildDocument(); const res = await server.post("/api/documents.export", { diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 2925ae6a43..779d6456eb 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -13,6 +13,7 @@ import { errToString } from "@shared/utils/error"; import type { DirectionFilter, SortFilter } from "@shared/types"; import { type NavigationNode } from "@shared/types"; import { + ExportContentType, FileOperationFormat, FileOperationState, FileOperationType, @@ -68,6 +69,7 @@ import AttachmentHelper from "@server/models/helpers/AttachmentHelper"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import HTMLHelper from "@server/models/helpers/HTMLHelper"; import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper"; +import TextBundleHelper from "@server/models/helpers/TextBundleHelper"; import SearchProviderManager from "@server/utils/SearchProviderManager"; import { TextHelper } from "@server/models/helpers/TextHelper"; import { authorize, cannot } from "@server/policies"; @@ -812,8 +814,12 @@ router.post( const document = await documentLoader({ id, user, - // We need the collaborative state to generate HTML. - includeState: !accept?.includes("text/markdown"), + // We need the collaborative state to generate HTML, but not for the + // formats that are written from markdown. + includeState: !( + accept?.includes("text/markdown") || + accept?.includes(ExportContentType.TextBundle) + ), }); authorize(user, "download", document); @@ -822,9 +828,11 @@ router.post( ? FileOperationFormat.HTMLZip : accept?.includes("text/markdown") ? FileOperationFormat.MarkdownZip - : accept?.includes("application/pdf") - ? FileOperationFormat.PDF - : null; + : accept?.includes(ExportContentType.TextBundle) + ? FileOperationFormat.TextBundleZip + : accept?.includes("application/pdf") + ? FileOperationFormat.PDF + : null; if (format === FileOperationFormat.PDF) { throw IncorrectEditionError( @@ -876,13 +884,17 @@ router.post( teamId: user.teamId, }); + // A TextBundle is a directory of files, so unlike the other formats it has + // no self-contained single-file form to fall back to. + const isTextBundle = format === FileOperationFormat.TextBundleZip; + if (format === FileOperationFormat.HTMLZip) { contentType = "text/html"; content = await DocumentHelper.toHTML(document, { centered: true, includeMermaid: true, }); - } else if (format === FileOperationFormat.MarkdownZip) { + } else if (isTextBundle || format === FileOperationFormat.MarkdownZip) { contentType = "text/markdown"; content = await toMarkdown(); } else { @@ -944,6 +956,44 @@ router.post( externalAttachments.push({ attachment, buffer }); } + if (isTextBundle) { + const root = `${fileName}.${TextBundleHelper.bundleExtension}`; + const usedAssetNames = new Set(); + + streamZipResponse( + ctx, + `${fileName}.${TextBundleHelper.packExtension}`, + (zip) => { + for (const { attachment, buffer } of externalAttachments) { + const reference = TextBundleHelper.assetPath( + attachment.name, + usedAssetNames + ); + zip.addBuffer(buffer, path.join(root, reference), { + mtime: attachment.updatedAt, + }); + + content = content.replace( + new RegExp(escapeRegExp(attachment.redirectUrl), "g"), + encodeURI(reference) + ); + } + + zip.addBuffer( + Buffer.from(TextBundleHelper.info(document)), + path.join(root, TextBundleHelper.infoFileName), + { mtime: document.updatedAt } + ); + zip.addBuffer( + Buffer.from(content), + path.join(root, TextBundleHelper.textFileName), + { mtime: document.updatedAt } + ); + } + ); + return; + } + // When there are no external attachments the document is self-contained and // can be served directly rather than bundled in a zip. if (externalAttachments.length === 0) { diff --git a/server/test/fixtures/textbundle-traversal.textpack b/server/test/fixtures/textbundle-traversal.textpack new file mode 100644 index 0000000000000000000000000000000000000000..42b6d9c2eaf72f001a986be52ce05dbada7161a1 GIT binary patch literal 479 zcmWIWW@Zs#0D(VC7-J=;{oQ2>WP`9I5c}nqr0SKVR+J=_=B4DM>SyMq;hQ}*Q~4%oS2r%#pRrzSCX1nqL7hVl&Z&tYG9Dz?_L(5K_Dy& z#Cm%A5R_Vyte;wznWI-yQ39d^ycwB9m~s0Ws22 +): Promise { + return new Promise((resolve, reject) => { + const zip = new ZipFile(); + const chunks: Buffer[] = []; + + zip.outputStream.on("data", (chunk: Buffer) => chunks.push(chunk)); + zip.outputStream.on("end", () => resolve(Buffer.concat(chunks))); + zip.outputStream.on("error", reject); + + for (const [name, contents] of Object.entries(files)) { + zip.addBuffer( + Buffer.isBuffer(contents) ? contents : Buffer.from(contents, "utf8"), + name + ); + } + zip.end(); + }); +} + /** * Helper function to convert an object to form-urlencoded string. * Useful for testing OAuth endpoints that expect application/x-www-form-urlencoded content type. diff --git a/server/utils/DocumentConverter.ts b/server/utils/DocumentConverter.ts deleted file mode 100644 index a308a20558..0000000000 --- a/server/utils/DocumentConverter.ts +++ /dev/null @@ -1,757 +0,0 @@ -import { escape, escapeRegExp } from "es-toolkit/compat"; -import type { Attachment as MailAttachment, ParsedMail } from "mailparser"; -import type { Node } from "prosemirror-model"; -import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; -import yaml from "js-yaml"; -import { schema, serializer } from "@server/editor"; -import { FileImportError } from "@server/errors"; -import { trace, traceFunction } from "@server/logging/tracing"; -import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper"; - -export interface ConvertResult { - /** The document content as markdown text. */ - text: string; - /** The document content as Prosemirror. */ - doc: Node; - /** The extracted title (from H1 heading if present). */ - title: string; - /** The extracted emoji/icon from start of document. */ - icon?: string; -} - -/** - * Converts incoming files of various formats to structured documents. - */ -@trace() -export class DocumentConverter { - /** - * Convert an incoming file to a structured document result. - * - * @param content The content of the file. - * @param fileName The name of the file, including extension. - * @param mimeType The mime type of the file. - * @param options Conversion options. - * @param options.extractTitle Whether a leading H1 heading should be lifted - * out as the document title and removed from the body. Defaults to true; - * set false for sources where the filename is authoritative and the first - * heading must remain part of the content (e.g. Slab). - * @returns The converted document with text, data, title, and icon. - */ - public static async convert( - content: Buffer | string, - fileName: string, - mimeType: string, - options: { extractTitle?: boolean } = {} - ): Promise { - const { extractTitle = true } = options; - let doc: Node; - - // Route to appropriate conversion method - const html = await this.convertToHtml(content, fileName, mimeType); - if (html !== undefined) { - doc = await this.htmlToProsemirror(html); - } else { - const markdown = await this.convertToMarkdown( - content, - fileName, - mimeType - ); - doc = ProsemirrorHelper.toProsemirror(markdown); - } - - // Extract title from first H1 heading - let title = ""; - if (extractTitle) { - const headings = ProsemirrorHelper.getHeadings(doc); - if (headings.length > 0 && headings[0].level === 1) { - title = headings[0].title; - doc = ProsemirrorHelper.removeFirstHeading(doc); - } - } - - // Extract emoji from start of document - const { emoji: icon, doc: docWithoutEmoji } = - ProsemirrorHelper.extractEmojiFromStart(doc); - doc = docWithoutEmoji; - - // Serialize to markdown and trim whitespace - const text = serializer.serialize(doc).trim(); - - return { - text, - doc, - title, - icon, - }; - } - - /** - * Convert HTML content directly to a Prosemirror document node. - * - * @param content The HTML content as a string or Buffer. - * @returns A Prosemirror Node representing the document. - */ - public static async htmlToProsemirror( - content: Buffer | string - ): Promise { - if (typeof content !== "string") { - content = content.toString("utf8"); - } - - // Loaded lazily to keep jsdom off the startup path — only HTML imports need it. - const { JSDOM } = await import("jsdom"); - const dom = new JSDOM(content); - const document = dom.window.document; - - // Remove problematic elements before parsing - const elementsToRemove = document.querySelectorAll( - "script, style, title, head, meta, link" - ); - elementsToRemove.forEach((el) => el.remove()); - - // Preprocess the DOM to handle edge cases - this.preprocessHtmlForImport(document); - - // Patch global environment for Prosemirror DOMParser - const cleanup = ProsemirrorHelper.patchGlobalEnv(dom.window); - - try { - const domParser = ProsemirrorDOMParser.fromSchema(schema); - return domParser.parse(document.body); - } finally { - cleanup(); - try { - dom.window.close(); - } catch (_err) { - // Best effort, closing the window releases its timers and resources. - } - } - } - - /** - * Preprocesses HTML DOM before Prosemirror parsing to cleanup - * images and other elements. - * - * @param document The DOM document to preprocess. - */ - private static preprocessHtmlForImport(document: Document): void { - // Handle images: filter emoticons, remove Jira icons, apply Confluence sizing - const images = document.querySelectorAll("img"); - images.forEach((img) => { - const className = img.className || ""; - - // Skip emoticon images (they'll be dropped) - if (className.includes("emoticon")) { - img.remove(); - return; - } - - // Remove Jira icon images - if ( - className === "icon" && - img.parentElement?.className.includes("jira-issue-key") - ) { - img.remove(); - return; - } - - // Handle Confluence image sizing: data-width/data-height → width/height - const dataWidth = img.getAttribute("data-width"); - const dataHeight = img.getAttribute("data-height"); - const width = img.getAttribute("width"); - - if (dataWidth && dataHeight && width) { - const ratio = parseInt(dataWidth) / parseInt(width); - const calculatedHeight = Math.round(parseInt(dataHeight) / ratio); - img.setAttribute("height", String(calculatedHeight)); - } - - // Extract dimensions from data URI images that lack width/height - // (e.g. images embedded by mammoth during docx import). - // Only decode a small prefix of the base64 data — headers for all - // supported formats live within the first 64 KB of the file. - if (!img.getAttribute("width") && !img.getAttribute("height")) { - const src = img.getAttribute("src") || ""; - if (src.startsWith("data:") && src.includes(";base64,")) { - const base64Start = src.indexOf(";base64,") + 8; - // 4 base64 chars → 3 bytes; decode at most ~64 KB of image data. - const maxBase64Chars = Math.ceil(65536 / 3) * 4; - const base64Prefix = src.slice( - base64Start, - base64Start + maxBase64Chars - ); - const dimensions = this.getImageDimensionsFromBuffer( - Buffer.from(base64Prefix, "base64") - ); - if (dimensions) { - img.setAttribute("width", String(dimensions.width)); - img.setAttribute("height", String(dimensions.height)); - } - } - } - }); - } - - /** - * Attempts to convert content to HTML for formats that support it. - * Returns undefined for formats that should be parsed as markdown directly. - * - * @param content The content of the file. - * @param fileName The name of the file, including extension. - * @param mimeType The mime type of the file. - * @returns HTML string if convertible, undefined otherwise. - */ - private static async convertToHtml( - content: Buffer | string, - fileName: string, - mimeType: string - ): Promise { - const extension = fileName.split(".").pop()?.toLowerCase(); - - // First try to convert based on the mime type - switch (mimeType) { - case "text/html": - return typeof content === "string" ? content : content.toString("utf8"); - case "application/msword": - return this.confluenceToHtml(content); - case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - return this.docxToHtml(content); - // Browsers report MHTML ("Save page as → Webpage, Single File") and .eml - // inconsistently across these three mime types, so the extension is - // used as a tie-breaker between the two archive kinds below. - case "multipart/related": - case "application/x-mimearchive": - return extension === "eml" - ? this.emlToHtml(content) - : this.mhtmlToHtml(content); - case "message/rfc822": - return extension === "mhtml" || extension === "mht" - ? this.mhtmlToHtml(content) - : this.emlToHtml(content); - default: - break; - } - - // Try to convert based on the file extension - switch (extension) { - case "htm": - case "html": - return typeof content === "string" ? content : content.toString("utf8"); - case "docx": - return this.docxToHtml(content); - case "mhtml": - case "mht": - return this.mhtmlToHtml(content); - case "eml": - return this.emlToHtml(content); - default: - return undefined; - } - } - - /** - * Converts content to markdown for text-based formats. - * - * @param content The content of the file. - * @param fileName The name of the file, including extension. - * @param mimeType The mime type of the file. - * @returns Markdown string. - */ - private static async convertToMarkdown( - content: Buffer | string, - fileName: string, - mimeType: string - ): Promise { - let markdown: string; - - switch (mimeType) { - case "text/plain": - case "text/markdown": - markdown = this.bufferToString(content); - break; - case "text/csv": - case "text/tab-separated-values": - return this.csvToMarkdown(content); - default: { - const extension = fileName.split(".").pop()?.toLowerCase(); - switch (extension) { - case "md": - case "markdown": - case "txt": - markdown = this.bufferToString(content); - break; - case "csv": - case "tsv": - return this.csvToMarkdown(content); - default: - throw FileImportError(`File type ${mimeType} not supported`); - } - } - } - - // Process frontmatter and convert it to a YAML codeblock - return this.processFrontmatter(markdown); - } - - /** - * Convert a docx file to HTML using mammoth. - * - * @param content The docx file content as a Buffer. - * @returns The HTML representation of the document. - */ - private static async docxToHtml(content: Buffer | string): Promise { - if (content instanceof Buffer) { - // Loaded lazily to keep mammoth off the startup path — only docx imports need it. - const mammoth = (await import("mammoth")).default; - const { value } = await traceFunction({ spanName: "convertToHtml" })( - mammoth.convertToHtml - )({ - buffer: content, - }); - return value; - } - throw FileImportError("Unsupported Word file"); - } - - /** Maximum number of MIME archive parts that will be inlined as data URIs. */ - private static readonly MIME_ARCHIVE_MAX_INLINE_PARTS = 100; - - /** Maximum size of a single MIME archive part that will be inlined as a data URI. */ - private static readonly MIME_ARCHIVE_MAX_PART_BYTES = 15 * 1024 * 1024; - - /** Maximum combined size of MIME archive parts that will be inlined as data URIs. */ - private static readonly MIME_ARCHIVE_MAX_TOTAL_BYTES = 50 * 1024 * 1024; - - /** Matches a well-formed mime type, e.g. `image/png`. */ - private static readonly MIME_TYPE_REGEX = /^[\w.+-]+\/[\w.+-]+$/; - - /** - * Convert a Confluence Word export to HTML. - * - * @param content The Confluence Word export content. - * @returns The HTML representation of the document. - */ - private static async confluenceToHtml( - content: Buffer | string - ): Promise { - const text = this.bufferToString(content); - - // We're only supporting the output from Confluence here, regular Word documents should call - // into the docxToHtml importer. See: https://jira.atlassian.com/browse/CONFSERVER-38237 - if (!text.includes("Content-Type: multipart/related")) { - throw FileImportError("Unsupported Word file"); - } - - const { html } = await this.parseMimeArchive(text, { - emptyMessage: "Unsupported Word file (No content found)", - }); - - return html; - } - - /** - * Convert an MHTML file (e.g. Chrome/Edge "Save page as → Webpage, Single - * File") to HTML. - * - * @param content The MHTML file content. - * @returns The HTML representation of the document. - */ - private static async mhtmlToHtml(content: Buffer | string): Promise { - const { html } = await this.parseMimeArchive(this.bufferToString(content), { - emptyMessage: "Unsupported MHTML file (No content found)", - }); - - return html; - } - - /** - * Convert an .eml email message to HTML. The `Subject` header, if present, - * is inserted as a leading H1 so it is picked up as the document title by - * the same leading-heading extraction used for every other format. - * - * @param content The .eml file content. - * @returns The HTML representation of the message. - */ - private static async emlToHtml(content: Buffer | string): Promise { - const { html, subject } = await this.parseMimeArchive( - this.bufferToString(content), - { - allowTextFallback: true, - emptyMessage: "Unsupported email file (No content found)", - } - ); - - return subject ? `

${escape(subject)}

\n${html}` : html; - } - - /** - * Parse a MIME archive (`multipart/related`, `message/rfc822`, or similar) - * with mailparser and resolve its HTML body, inlining referenced parts as - * data URIs. - * - * Confluence's "Word" export and MHTML pages (Chrome/Edge "Save page as → - * Webpage, Single File") are both, structurally, multi-part email messages, - * so the same parser and inlining logic serves all of them. - * - * @param content The MIME archive content as a string. - * @param options.allowTextFallback Whether to fall back to the plain text - * body (rendered as HTML) when no HTML part is present. Used for .eml, - * where a text-only email is common; MHTML/Word exports always have HTML. - * @param options.emptyMessage The error message to throw when no usable - * body is found. - * @returns The resolved HTML body and, if present, the message subject. - */ - private static async parseMimeArchive( - content: string, - options: { allowTextFallback?: boolean; emptyMessage: string } - ): Promise<{ html: string; subject?: string }> { - // Confluence "Word" documents, MHTML pages, and .eml files are all just multi-part - // email messages, so we can use mailparser to parse the content. Loaded lazily to - // keep mailparser off the startup path — only these formats need it. `keepCidLinks` - // is set so we can apply our own bounds when inlining referenced parts below, rather - // than mailparser inlining every matching part unconditionally. - const { simpleParser } = await import("mailparser"); - - let parsed: ParsedMail; - try { - parsed = await simpleParser(content, { keepCidLinks: true }); - } catch (_err) { - throw FileImportError(options.emptyMessage); - } - - let html = parsed.html || undefined; - if (!html && options.allowTextFallback) { - html = parsed.textAsHtml || undefined; - } - if (!html) { - throw FileImportError(options.emptyMessage); - } - - return { - html: this.inlineMimeArchiveParts(html, parsed.attachments), - subject: parsed.subject, - }; - } - - /** - * Replace references to MIME archive parts within HTML with data URIs, so - * the resulting document is self-contained. Parts are referenced either by - * `Content-Location` (used by MHTML and Confluence exports) or by - * `Content-ID` as a `cid:` URL (used by email). Inlining is bounded so a - * hostile or merely enormous archive cannot blow up memory or the - * resulting document. - * - * @param html The HTML body that may reference archive parts. - * @param attachments The parsed MIME archive parts. - * @returns The HTML with resolvable references replaced by data URIs. - */ - private static inlineMimeArchiveParts( - html: string, - attachments: MailAttachment[] - ): string { - let inlinedParts = 0; - let inlinedBytes = 0; - - for (const attachment of attachments) { - if (inlinedParts >= this.MIME_ARCHIVE_MAX_INLINE_PARTS) { - break; - } - if ( - attachment.content.length > this.MIME_ARCHIVE_MAX_PART_BYTES || - inlinedBytes + attachment.content.length > - this.MIME_ARCHIVE_MAX_TOTAL_BYTES - ) { - continue; - } - - const references = new Set(); - const contentLocation = attachment.headers.get("content-location") as - | string - | undefined; - if (contentLocation) { - references.add(contentLocation); - const basename = contentLocation.split("/").pop(); - if (basename) { - references.add(basename); - } - } - if (attachment.cid) { - references.add(`cid:${attachment.cid}`); - } - - // The content type comes from the archive's own headers, so anything that - // isn't a well-formed mime type is discarded rather than interpolated. - const contentType = this.MIME_TYPE_REGEX.test(attachment.contentType) - ? attachment.contentType - : "application/octet-stream"; - - let replaced = false; - for (const reference of references) { - if (html.includes(reference)) { - const dataUri = `data:${contentType};base64,${attachment.content.toString( - "base64" - )}`; - html = html.split(reference).join(dataUri); - replaced = true; - } - } - - if (replaced) { - inlinedParts++; - inlinedBytes += attachment.content.length; - } - } - - return html; - } - - /** - * Convert a CSV file to a markdown table. - * - * @param content The CSV file content. - * @returns A markdown table representation. - */ - private static async csvToMarkdown( - content: Buffer | string - ): Promise { - // Loaded lazily to keep @fast-csv off the startup path — only CSV imports need it. - const { parse } = await import("@fast-csv/parse"); - - return new Promise((resolve, reject) => { - const text = this.bufferToString(content).trim(); - const textLines = text.split("\n"); - - // Find the first non-empty line to determine the delimiter - const firstNonEmptyLine = - textLines.find((line) => line.trim().length > 0) || ""; - - // Determine the separator used in the CSV file based on number of occurrences of each separator on first line - const delimiter = [";", ",", "\t"].reduce( - (acc, separator) => { - const count = ( - firstNonEmptyLine.match(new RegExp(escapeRegExp(separator), "g")) || - [] - ).length; - return count > acc.count ? { count, separator } : acc; - }, - { count: 0, separator: "," } - ).separator; - - const lines: string[][] = []; - const stream = parse({ delimiter }) - .on("error", (error) => { - reject( - FileImportError(`There was an error parsing the CSV file: ${error}`) - ); - }) - .on("data", (row) => lines.push(row)) - .on("end", () => { - // Filter out completely empty rows - const nonEmptyLines = lines.filter((row) => - row.some((cell) => cell.trim() !== "") - ); - - if (nonEmptyLines.length === 0) { - resolve(""); - return; - } - - // Check if all rows have a trailing empty cell (trailing comma artifact) - // Only trim if ALL non-empty rows end with an empty cell - let trimmedLines = nonEmptyLines; - while ( - trimmedLines.length > 0 && - trimmedLines.every( - (row) => row.length > 0 && row[row.length - 1].trim() === "" - ) - ) { - trimmedLines = trimmedLines.map((row) => row.slice(0, -1)); - } - - // Find the most common column count - const columnCounts = new Map(); - for (const row of trimmedLines) { - if (row.length > 0) { - columnCounts.set( - row.length, - (columnCounts.get(row.length) || 0) + 1 - ); - } - } - - // Get the column count that appears most frequently - let expectedColumns = 0; - let maxFrequency = 0; - for (const [count, frequency] of columnCounts) { - if (frequency > maxFrequency) { - maxFrequency = frequency; - expectedColumns = count; - } - } - - // Find the first row with the expected column count (this is the header) - const headerIndex = trimmedLines.findIndex( - (row) => row.length === expectedColumns - ); - if (headerIndex === -1) { - resolve(""); - return; - } - - const headers = trimmedLines[headerIndex]; - const dataRows = trimmedLines - .slice(headerIndex + 1) - .filter((row) => row.length === expectedColumns); - - const table = dataRows - .map((cells) => `| ${cells.join(" | ")} |`) - .join("\n"); - - const headerLine = `| ${headers.join(" | ")} |`; - const separatorLine = `| ${headers.map(() => "---").join(" | ")} |`; - - resolve(`${headerLine}\n${separatorLine}\n${table}\n`); - }); - - stream.write(text); - stream.end(); - }); - } - - /** - * Convert a Buffer to a string. - * - * @param content The content as a Buffer or string. - * @returns The content as a string. - */ - private static bufferToString(content: Buffer | string): string { - return typeof content === "string" ? content : content.toString("utf8"); - } - - /** - * Parse and convert frontmatter to a YAML codeblock. - * - * @param content The markdown content that may contain frontmatter. - * @returns The markdown content with frontmatter converted to a YAML codeblock. - */ - private static processFrontmatter(content: string): string { - // Frontmatter must start at the beginning of the document - const frontmatterRegex = /^---\n([\s\S]*?)\n---(?:\n|$)/; - const match = content.match(frontmatterRegex); - - if (!match) { - return content; - } - - const frontmatterContent = match[1]; - const remainingContent = content.slice(match[0].length); - - // Validate that the frontmatter is valid YAML - try { - yaml.load(frontmatterContent); - } catch { - // If it's not valid YAML, return content unchanged - return content; - } - - // Convert frontmatter to a YAML codeblock - const codeBlockDelimiter = "```"; - const yamlCodeblock = `${codeBlockDelimiter}yaml\n${frontmatterContent}\n${codeBlockDelimiter}\n\n`; - - return yamlCodeblock + remainingContent; - } - - /** - * Parse image dimensions from a binary buffer. Supports PNG, JPEG, and GIF. - * - * @param buffer The image data. - * @returns The width and height if parseable, otherwise undefined. - */ - private static getImageDimensionsFromBuffer( - buffer: Buffer - ): { width: number; height: number } | undefined { - try { - // PNG: signature + IHDR chunk - if ( - buffer.length >= 24 && - buffer[0] === 0x89 && - buffer[1] === 0x50 && - buffer[2] === 0x4e && - buffer[3] === 0x47 - ) { - return { - width: buffer.readUInt32BE(16), - height: buffer.readUInt32BE(20), - }; - } - - // GIF: signature + logical screen descriptor - if ( - buffer.length >= 10 && - buffer[0] === 0x47 && - buffer[1] === 0x49 && - buffer[2] === 0x46 - ) { - return { - width: buffer.readUInt16LE(6), - height: buffer.readUInt16LE(8), - }; - } - - // JPEG: scan for SOF marker (cap at 64 KB to bound work) - if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xd8) { - const scanLimit = Math.min(buffer.length, 65536); - let offset = 2; - while (offset + 1 < scanLimit) { - if (buffer[offset] !== 0xff) { - offset++; - continue; - } - const marker = buffer[offset + 1]; - offset += 2; - - // Standalone markers without a payload - if ( - marker === 0x00 || - marker === 0x01 || - (marker >= 0xd0 && marker <= 0xd9) - ) { - continue; - } - - if (offset + 2 > scanLimit) { - break; - } - const segmentLength = buffer.readUInt16BE(offset); - - // SOF markers contain the frame dimensions — check before - // the advance guard since this returns immediately. - if ( - (marker >= 0xc0 && marker <= 0xc3) || - (marker >= 0xc5 && marker <= 0xc7) || - (marker >= 0xc9 && marker <= 0xcb) || - (marker >= 0xcd && marker <= 0xcf) - ) { - if (offset + 7 <= buffer.length) { - return { - height: buffer.readUInt16BE(offset + 3), - width: buffer.readUInt16BE(offset + 5), - }; - } - break; - } - - // Length includes itself and must be >= 2; bail on malformed data. - if (segmentLength < 2 || offset + segmentLength > buffer.length) { - break; - } - - offset += segmentLength; - } - } - } catch { - // Return undefined if parsing fails - } - return undefined; - } -} diff --git a/server/utils/ZipHelper.test.ts b/server/utils/ZipHelper.test.ts index b1d3ced24e..9bfc5de58e 100644 --- a/server/utils/ZipHelper.test.ts +++ b/server/utils/ZipHelper.test.ts @@ -4,6 +4,7 @@ import fs from "fs-extra"; import tmp from "tmp"; import { vi } from "vitest"; import { ZipFile } from "yazl"; +import { buildZip } from "@server/test/support"; import ZipHelper from "./ZipHelper"; async function writeZip( @@ -228,3 +229,36 @@ describe("ZipHelper.toFileTree", () => { expect(sizes).toEqual({ "Collection/page.md": 11 }); }); }); + +describe("ZipHelper.walk", () => { + it("walks an archive held in memory", async () => { + const buffer = await buildZip({ + "Collection/page.md": "hello world", + "Collection/image.png": "binary", + }); + + const contents: Record = {}; + await ZipHelper.walk(buffer, async (entry) => { + if (!entry.isDirectory) { + contents[entry.fileName] = (await entry.readBuffer(1024)).toString( + "utf8" + ); + } + }); + + expect(contents).toEqual({ + "Collection/page.md": "hello world", + "Collection/image.png": "binary", + }); + }); + + it("rejects an oversized entry in an archive held in memory", async () => { + const buffer = await buildZip({ "Collection/page.md": "hello world" }); + + await expect( + ZipHelper.walk(buffer, async (entry) => { + await entry.readBuffer(4); + }) + ).rejects.toThrow("Collection/page.md is too large"); + }); +}); diff --git a/server/utils/ZipHelper.ts b/server/utils/ZipHelper.ts index e39f727c26..91239f36d2 100644 --- a/server/utils/ZipHelper.ts +++ b/server/utils/ZipHelper.ts @@ -105,120 +105,139 @@ export default class ZipHelper { * Entries are visited serially in archive order. `onEntry` may be async; the * next entry is only read once the previous handler resolves. * - * @param filePath The file path where the zip is located. + * @param source The file path where the zip is located, or a Buffer holding + * the zip contents already in memory. * @param onEntry Handler invoked for each entry. Skip an entry by returning * without calling `entry.readBuffer(maxSize)`. * @returns Promise that resolves once the archive has been fully walked. */ public static walk( - filePath: string, + source: string | Buffer, onEntry: (entry: ZipEntryHandle) => Promise | void ): Promise { return new Promise((resolve, reject) => { - yauzl.open( - filePath, - { - lazyEntries: true, - autoClose: true, - decodeStrings: false, - }, - function (err, zipfile) { - if (err) { - return reject(err); + const options = { + lazyEntries: true, + autoClose: true, + decodeStrings: false, + }; + + const onOpen = function ( + err: Error | null, + zipfile: yauzl.ZipFile + ): void { + if (err) { + return reject(err); + } + + let settled = false; + const fail = (error: Error) => { + if (settled) { + return; + } + settled = true; + zipfile.close(); + reject(error); + }; + + zipfile.on("entry", (entry: Entry) => { + const fileName = Buffer.from(entry.fileName).toString("utf8"); + + if (validateFileName(fileName)) { + Logger.warn("Invalid zip entry", { fileName }); + zipfile.readEntry(); + return; } - let settled = false; - const fail = (error: Error) => { - if (settled) { - return; - } - settled = true; - zipfile.close(); - reject(error); + const handle: ZipEntryHandle = { + fileName, + uncompressedSize: entry.uncompressedSize, + isDirectory: fileName.endsWith("/"), + readBuffer: (maxSize) => + new Promise((res, rej) => { + if (entry.uncompressedSize > maxSize) { + return rej(ZipHelper.entryTooLargeError(fileName, maxSize)); + } + + zipfile.openReadStream(entry, (rErr, readStream) => { + if (rErr) { + return rej(rErr); + } + const chunks: Buffer[] = []; + let bytesRead = 0; + let settled = false; + readStream.on("data", (chunk: Buffer) => { + bytesRead += chunk.length; + if (bytesRead > maxSize) { + readStream.destroy( + ZipHelper.entryTooLargeError(fileName, maxSize) + ); + return; + } + chunks.push(chunk); + }); + readStream.on("end", () => { + if (!settled) { + settled = true; + res(Buffer.concat(chunks)); + } + }); + readStream.on("error", (err) => { + if (!settled) { + settled = true; + rej(err); + } + }); + readStream.on("close", () => { + if (!settled) { + settled = true; + rej( + new Error( + `Stream closed before completing read of ${fileName}` + ) + ); + } + }); + }); + }), }; - zipfile.on("entry", (entry: Entry) => { - const fileName = Buffer.from(entry.fileName).toString("utf8"); + Promise.resolve() + .then(() => onEntry(handle)) + .then(() => { + if (!settled) { + zipfile.readEntry(); + } + }) + .catch(fail); + }); - if (validateFileName(fileName)) { - Logger.warn("Invalid zip entry", { fileName }); - zipfile.readEntry(); - return; - } + const done = () => { + if (!settled) { + settled = true; + resolve(); + } + }; - const handle: ZipEntryHandle = { - fileName, - uncompressedSize: entry.uncompressedSize, - isDirectory: fileName.endsWith("/"), - readBuffer: (maxSize) => - new Promise((res, rej) => { - if (entry.uncompressedSize > maxSize) { - return rej(ZipHelper.entryTooLargeError(fileName, maxSize)); - } - - zipfile.openReadStream(entry, (rErr, readStream) => { - if (rErr) { - return rej(rErr); - } - const chunks: Buffer[] = []; - let bytesRead = 0; - let settled = false; - readStream.on("data", (chunk: Buffer) => { - bytesRead += chunk.length; - if (bytesRead > maxSize) { - readStream.destroy( - ZipHelper.entryTooLargeError(fileName, maxSize) - ); - return; - } - chunks.push(chunk); - }); - readStream.on("end", () => { - if (!settled) { - settled = true; - res(Buffer.concat(chunks)); - } - }); - readStream.on("error", (err) => { - if (!settled) { - settled = true; - rej(err); - } - }); - readStream.on("close", () => { - if (!settled) { - settled = true; - rej( - new Error( - `Stream closed before completing read of ${fileName}` - ) - ); - } - }); - }); - }), - }; - - Promise.resolve() - .then(() => onEntry(handle)) - .then(() => { - if (!settled) { - zipfile.readEntry(); - } - }) - .catch(fail); - }); - - zipfile.on("close", () => { - if (!settled) { - settled = true; - resolve(); - } - }); - zipfile.on("error", (error) => fail(error)); - zipfile.readEntry(); + // A file-backed archive resolves on "close", so that its descriptor is + // released before the caller gets control back and can delete the file. + // A buffer-backed one has no descriptor and yauzl's BufferSlicer never + // emits "close", so "end" — every entry read — is the only signal. + if (typeof source === "string") { + zipfile.on("close", done); + } else { + zipfile.on("end", done); } - ); + + zipfile.on("error", (error) => fail(error)); + zipfile.readEntry(); + }; + + if (typeof source === "string") { + yauzl.open(source, options, onOpen); + } else { + yauzl.fromBuffer(source, options, onOpen); + } }); } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 2217424f08..1cd7b11c0a 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -81,6 +81,7 @@ "Download document": "Download document", "Download as Markdown": "Download as Markdown", "Download as HTML": "Download as HTML", + "Download as TextBundle": "Download as TextBundle", "Download as PDF": "Download as PDF", "Copy as Markdown": "Copy as Markdown", "Markdown copied to clipboard": "Markdown copied to clipboard", @@ -300,6 +301,7 @@ "Preparing your download": "Preparing your download", "A file containing the selected documents in Markdown format.": "A file containing the selected documents in Markdown format.", "A file containing the selected documents in HTML format.": "A file containing the selected documents in HTML format.", + "A file containing the selected documents and their images in TextBundle format.": "A file containing the selected documents and their images in TextBundle format.", "A file containing the selected documents in PDF format.": "A file containing the selected documents in PDF format.", "Include child documents": "Include child documents", "When selected, exporting the document {{documentName}} may take some time.": "When selected, exporting the document {{documentName}} may take some time.", @@ -379,6 +381,7 @@ "Show detail": "Show detail", "A ZIP file containing the images, and documents in the Markdown format.": "A ZIP file containing the images, and documents in the Markdown format.", "A ZIP file containing the images, and documents as HTML files.": "A ZIP file containing the images, and documents as HTML files.", + "A ZIP file containing each document and its images as a TextBundle.": "A ZIP file containing each document and its images as a TextBundle.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Structured data that can be used to transfer data to another compatible {{ appName }} instance.", "Exporting the collection {{collectionName}} may take some time.": "Exporting the collection {{collectionName}} may take some time.", "Include attachments": "Include attachments", diff --git a/shared/types.ts b/shared/types.ts index b8e37e439e..2be0c35459 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -56,6 +56,7 @@ export enum Client { export enum ExportContentType { Markdown = "text/markdown", Html = "text/html", + TextBundle = "application/x-textbundle", Pdf = "application/pdf", } @@ -63,6 +64,7 @@ export enum FileOperationFormat { JSON = "json", MarkdownZip = "outline-markdown", HTMLZip = "html", + TextBundleZip = "textbundle", PDF = "pdf", Notion = "notion", } diff --git a/shared/utils/markdown.test.ts b/shared/utils/markdown.test.ts new file mode 100644 index 0000000000..977308f054 --- /dev/null +++ b/shared/utils/markdown.test.ts @@ -0,0 +1,53 @@ +import { replaceMarkdownLinks } from "./markdown"; + +describe("replaceMarkdownLinks", () => { + const toUpper = (href: string) => href.toUpperCase(); + + it("replaces the destination of a link, leaving its label", () => { + expect(replaceMarkdownLinks("see [other](./other.md)", toUpper)).toBe( + "see [other](./OTHER.MD)" + ); + }); + + it("replaces the destination of an image", () => { + expect(replaceMarkdownLinks("![alt](assets/a.png)", toUpper)).toBe( + "![alt](ASSETS/A.PNG)" + ); + }); + + it("preserves a link title", () => { + expect( + replaceMarkdownLinks('![alt](assets/a.png "A caption")', toUpper) + ).toBe('![alt](ASSETS/A.PNG "A caption")'); + }); + + it("understands an angle bracketed destination", () => { + expect(replaceMarkdownLinks("![alt]()", toUpper)).toBe( + "![alt](ASSETS/MY A.PNG)" + ); + }); + + it("preserves a title after an angle bracketed destination", () => { + expect(replaceMarkdownLinks('[x]( "Title")', toUpper)).toBe( + '[x](MY DOC.MD "Title")' + ); + }); + + it("leaves a link alone when the replacer returns undefined", () => { + expect(replaceMarkdownLinks("[x](./y.md)", () => undefined)).toBe( + "[x](./y.md)" + ); + }); + + it("leaves an unterminated angle bracket alone", () => { + expect(replaceMarkdownLinks("[x]( { + expect(replaceMarkdownLinks("[a](one.md) and [b](two.md)", toUpper)).toBe( + "[a](ONE.MD) and [b](TWO.MD)" + ); + }); +}); diff --git a/shared/utils/markdown.ts b/shared/utils/markdown.ts index 9565fdfaa0..07566f7010 100644 --- a/shared/utils/markdown.ts +++ b/shared/utils/markdown.ts @@ -38,3 +38,57 @@ export const escape = function (text: string) { export const unescape = function (text: string) { return text.replace(/\\([\\*+-\d.])/g, "$1"); }; + +/** + * Matches a markdown link or image, capturing the text between its + * parentheses. A destination containing an unescaped closing parenthesis is + * not matched. + */ +const linkRegex = /(!?\[[^\]]*\]\()([^)]*)(\))/g; + +/** + * Replaces the destination of every markdown link and image in a string. + * + * Understands the angle-bracket form used when a destination contains spaces, + * and preserves any link title. + * + * @param text The markdown text to rewrite. + * @param replace Called with each destination; return the replacement, or + * undefined to leave the link as it was written. + * @returns The markdown with replaced destinations. + */ +export function replaceMarkdownLinks( + text: string, + replace: (href: string) => string | undefined +): string { + return text.replace( + linkRegex, + (match, prefix: string, target: string, suffix: string) => { + const leading = target.length - target.trimStart().length; + const trimmed = target.trim(); + + let href: string; + let title: string; + + if (trimmed.startsWith("<")) { + const end = trimmed.indexOf(">"); + if (end === -1) { + return match; + } + href = trimmed.slice(1, end); + title = target.slice(leading + end + 1); + } else { + [href] = trimmed.split(/\s/, 1); + if (!href) { + return match; + } + title = target.slice(leading + href.length); + } + + const replacement = replace(href); + return replacement === undefined + ? match + : `${prefix}${replacement}${title}${suffix}`; + } + ); +} diff --git a/shared/utils/parseTitle.ts b/shared/utils/parseTitle.ts index 759345bb13..e6a9adebd3 100644 --- a/shared/utils/parseTitle.ts +++ b/shared/utils/parseTitle.ts @@ -1,23 +1,36 @@ import emojiRegex from "emoji-regex"; import { unescape } from "./markdown"; -export default function parseTitle(text = "") { - const regex = emojiRegex(); +/** + * Splits a leading emoji from the start of a string. + * + * @param text The text to split. + * @returns The leading emoji, if there is one, and the remaining text. + */ +export function splitLeadingEmoji(text: string): { + emoji?: string; + rest: string; +} { + const matches = emojiRegex().exec(text); + const firstEmoji = matches ? matches[0] : null; + if (!firstEmoji || !text.startsWith(firstEmoji)) { + return { rest: text }; + } + + return { + emoji: firstEmoji, + rest: text.slice(firstEmoji.length).trim(), + }; +} + +export default function parseTitle(text = "") { // find and extract title const firstLine = text.trim().split(/\r?\n/)[0]; const title = unescape(firstLine.replace(/^#/, "").trim()); // find and extract first emoji - const matches = regex.exec(title); - const firstEmoji = matches ? matches[0] : null; - const startsWithEmoji = firstEmoji && title.startsWith(firstEmoji); - const emoji = startsWithEmoji ? firstEmoji : undefined; - - // title with first leading emoji stripped - const strippedTitle = startsWithEmoji - ? title.replace(firstEmoji, "").trim() - : title; + const { emoji, rest: strippedTitle } = splitLeadingEmoji(title); return { title,