diff --git a/apps/roam/package.json b/apps/roam/package.json index 7aabf89db..3b6d66735 100644 --- a/apps/roam/package.json +++ b/apps/roam/package.json @@ -24,6 +24,7 @@ "@repo/types": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/file-saver": "2.0.5", + "@types/mime-db": "^1.43.6", "@types/nanoid": "2.0.0", "@types/node": "catalog:", "@types/react": "catalog:roam", @@ -77,6 +78,7 @@ "file-saver": "2.0.2", "fuzzy": "^0.1.3", "lodash.isequal": "^4.5.0", + "mime-db": "^1.54.0", "minisearch": "^7.2.0", "nanoid": "2.0.4", "posthog-js": "catalog:", diff --git a/apps/roam/src/utils/__tests__/rewriteAssetLinks.test.ts b/apps/roam/src/utils/__tests__/rewriteAssetLinks.test.ts new file mode 100644 index 000000000..3456226fa --- /dev/null +++ b/apps/roam/src/utils/__tests__/rewriteAssetLinks.test.ts @@ -0,0 +1,564 @@ +import { describe, expect, it } from "vitest"; +import { + obsidianOriginNodeExample, + roamOriginNodeExample, +} from "@repo/database/crossAppNodeContract.example"; +import { rewriteAssetLinks } from "../rewriteAssetLinks"; + +const MIRRORED = + "https://firebasestorage.googleapis.com/v0/b/f.appspot.com/o/x?alt=media&token=abc"; +const OTHER_MIRRORED = + "https://firebasestorage.googleapis.com/v0/b/f.appspot.com/o/y?alt=media&token=def"; +const EXTERNAL = "https://example.org/paper.pdf"; + +describe("rewriteAssetLinks", () => { + it("rewrites an image embed to this graph's copy", () => { + expect( + rewriteAssetLinks({ + markdown: `![](vault/diagram.png)`, + assets: [{ sourceLocator: "vault/diagram.png", url: MIRRORED }], + }), + ).toBe(`![](${MIRRORED})`); + }); + + /** + * CommonMark wraps a destination containing spaces in angle brackets, and Obsidian can + * emit that form for a vault path. The brackets delimit the locator rather than belong + * to it, so what matches a recorded row is the text between them. + */ + it("resolves an image whose destination is wrapped in angle brackets", () => { + expect( + rewriteAssetLinks({ + markdown: `![]()`, + assets: [{ sourceLocator: "my folder/diagram.png", url: MIRRORED }], + }), + ).toBe(`![](${MIRRORED})`); + }); + + it("resolves a bracketed link and keeps its label", () => { + expect( + rewriteAssetLinks({ + markdown: `[the report]()`, + assets: [{ sourceLocator: "my folder/report.docx", url: MIRRORED }], + }), + ).toBe(`[the report](${MIRRORED})`); + }); + + /** + * An autolink is a bare URL the source wrapped in angle brackets. The brackets are part + * of the match, not of the locator, so the rewrite takes them with it. Capturing only + * the URL inside would leave `<` and `>` around the result, which Roam renders as text. + */ + it("rewrites an autolink without leaving its brackets behind", () => { + expect( + rewriteAssetLinks({ + markdown: `<${EXTERNAL}>`, + assets: [{ sourceLocator: EXTERNAL, url: MIRRORED }], + }), + ).toBe(`{{[[pdf]]: ${MIRRORED}}}`); + }); + + it("leaves an autolink alone when no row matches it", () => { + const markdown = `<${EXTERNAL}>`; + expect(rewriteAssetLinks({ markdown, assets: [] })).toBe(markdown); + }); + + it("keeps the alt text an image already carried", () => { + expect( + rewriteAssetLinks({ + markdown: `![the setup](vault/diagram.png)`, + assets: [{ sourceLocator: "vault/diagram.png", url: MIRRORED }], + }), + ).toBe(`![the setup](${MIRRORED})`); + }); + + it("writes a non-media asset as a labelled link, not a bare URL", () => { + // A bare URL renders as a link whose visible text is the URL, which tells the reader + // nothing about what the file is. + expect( + rewriteAssetLinks({ + markdown: `[](attachments/report.docx)`, + assets: [ + { + sourceLocator: "attachments/report.docx", + url: MIRRORED, + sourcePath: "attachments/report.docx", + }, + ], + }), + ).toBe(`[report.docx](${MIRRORED})`); + }); + + it("embeds any image type, matching what Roam does with a native upload", () => { + // Verified against `file.upload`: Roam takes the first part of the MIME type, so + // every `image/*` embeds, `.psd` included. Rendering it as a link here would make + // the same file look different depending on how it arrived in the graph. + expect( + rewriteAssetLinks({ + markdown: `[](vault/layers.psd)`, + assets: [ + { + sourceLocator: "vault/layers.psd", + url: MIRRORED, + sourcePath: "vault/layers.psd", + mimetype: "image/vnd.adobe.photoshop", + }, + ], + }), + ).toBe(`![](${MIRRORED})`); + }); + + it("knows every extension a renderable type claims, not just the common spelling", () => { + // `.qt` is video/quicktime just as `.mov` is, so listing one and not the other is an + // accident the extension table should not be able to have. + expect( + rewriteAssetLinks({ + markdown: `[](vault/clip.qt)`, + assets: [{ sourceLocator: "vault/clip.qt", url: MIRRORED }], + }), + ).toBe(`{{[[video]]: ${MIRRORED}}}`); + }); + + it("still resolves every extension the renderer depends on", () => { + // A guard rather than a new behaviour. The table is indexed from all of `mime-db`, + // so nothing here is hand-written and nothing pins these extensions except this + // test: a change in the data, or in how it is indexed, would silently degrade a + // common asset to a labelled link. + const shapeFor: Record string> = { + image: (url) => `![](${url})`, + pdf: (url) => `{{[[pdf]]: ${url}}}`, + audio: (url) => `{{[[audio]]: ${url}}}`, + video: (url) => `{{[[video]]: ${url}}}`, + }; + const expected: Record = { + png: "image", + jpg: "image", + jpeg: "image", + gif: "image", + webp: "image", + svg: "image", + bmp: "image", + avif: "image", + heic: "image", + tiff: "image", + ico: "image", + pdf: "pdf", + mp3: "audio", + wav: "audio", + ogg: "audio", + m4a: "audio", + flac: "audio", + aac: "audio", + mp4: "video", + webm: "video", + mov: "video", + m4v: "video", + mkv: "video", + avi: "video", + }; + for (const [extension, kind] of Object.entries(expected)) { + const sourceLocator = `vault/file.${extension}`; + expect( + rewriteAssetLinks({ + markdown: `[](${sourceLocator})`, + assets: [{ sourceLocator, url: MIRRORED }], + }), + ).toBe(shapeFor[kind]?.(MIRRORED)); + } + }); + + it("keeps a deliberate link's text for every kind, not only images", () => { + // Roam's media embeds carry no text, so embedding a link would delete the only words + // the reader sees. That is true of a PDF exactly as it is of an image. + expect( + rewriteAssetLinks({ + markdown: `[Read the protocol](notes/report.pdf)`, + assets: [{ sourceLocator: "notes/report.pdf", url: MIRRORED }], + }), + ).toBe(`[Read the protocol](${MIRRORED})`); + expect( + rewriteAssetLinks({ + markdown: `[Listen here](a.mp3)`, + assets: [{ sourceLocator: "a.mp3", url: MIRRORED }], + }), + ).toBe(`[Listen here](${MIRRORED})`); + }); + + it("keeps a wikilink's alias as the link text", () => { + // `[[x]]` is a link in Obsidian, not an embed, and the alias is the author's words. + expect( + rewriteAssetLinks({ + markdown: `[[vault/d.png|Figure 3]]`, + assets: [{ sourceLocator: "vault/d.png", url: MIRRORED }], + }), + ).toBe(`[Figure 3](${MIRRORED})`); + expect( + rewriteAssetLinks({ + markdown: `[[notes/report.docx|the protocol]]`, + assets: [ + { + sourceLocator: "notes/report.docx", + url: MIRRORED, + sourcePath: "notes/report.docx", + }, + ], + }), + ).toBe(`[the protocol](${MIRRORED})`); + }); + + it("ignores an image embed's pipe, which sizes rather than names", () => { + expect( + rewriteAssetLinks({ + markdown: `![[img.png|300]]`, + assets: [{ sourceLocator: "img.png", url: MIRRORED }], + }), + ).toBe(`![](${MIRRORED})`); + }); + + /** + * Obsidian's pipe means a width on an image embed and a label on anything else. Roam + * renders a non-media asset as a labelled link, so the label has somewhere to go, and + * dropping it would replace the author's words with a filename. + */ + it("keeps a non-media embed's pipe, which names rather than sizes", () => { + expect( + rewriteAssetLinks({ + markdown: `![[notes/report.docx|the protocol]]`, + assets: [{ sourceLocator: "notes/report.docx", url: MIRRORED }], + }), + ).toBe(`[the protocol](${MIRRORED})`); + }); + + it("falls back to the recorded name when a non-media embed has no pipe", () => { + expect( + rewriteAssetLinks({ + markdown: `![[notes/report.docx]]`, + assets: [{ sourceLocator: "notes/report.docx", url: MIRRORED }], + }), + ).toBe(`[report.docx](${MIRRORED})`); + }); + + it("does not let a bracket in a recorded name break the link", () => { + // `Paper [draft].pdf` is an ordinary attachment name. Emitted raw it ends the label + // early and the rest of the link leaks into the page as literal text. + const rewritten = rewriteAssetLinks({ + markdown: `[](x.docx)`, + assets: [ + { + sourceLocator: "x.docx", + url: MIRRORED, + sourcePath: "Paper [draft].docx", + }, + ], + }); + expect(rewritten).toBe(`[Paper draft.docx](${MIRRORED})`); + }); + + it("reads a recorded MIME type that carries case or parameters", () => { + // `mimetype` comes from a `FileReference` row, not from `mime-db`, so it is not + // normalised for us. An exact-match miss here silently drops to the extension rank. + expect( + rewriteAssetLinks({ + markdown: `[](vault/d.png)`, + assets: [ + { + sourceLocator: "vault/d.png", + url: MIRRORED, + mimetype: "IMAGE/PNG", + }, + ], + }), + ).toBe(`![](${MIRRORED})`); + expect( + rewriteAssetLinks({ + markdown: `[](vault/notes)`, + assets: [ + { + sourceLocator: "vault/notes", + url: MIRRORED, + mimetype: "application/pdf; charset=binary", + }, + ], + }), + ).toBe(`{{[[pdf]]: ${MIRRORED}}}`); + }); + + it("leaves an external link untouched, because no row matches it", () => { + const markdown = `See [the paper](${EXTERNAL}) and ![](${EXTERNAL})`; + expect( + rewriteAssetLinks({ + markdown, + assets: [{ sourceLocator: "vault/diagram.png", url: MIRRORED }], + }), + ).toBe(markdown); + }); + + it("uses Roam's own embed syntax for media types", () => { + const assets = [ + { sourceLocator: "a.pdf", url: MIRRORED, mimetype: "application/pdf" }, + { sourceLocator: "b.mp3", url: OTHER_MIRRORED }, + ]; + expect( + rewriteAssetLinks({ markdown: `![](a.pdf) and ![](b.mp3)`, assets }), + ).toBe(`{{[[pdf]]: ${MIRRORED}}} and {{[[audio]]: ${OTHER_MIRRORED}}}`); + }); + + it("resolves a Roam-origin media embed through its row rather than passing it through", () => { + const published = `${MIRRORED.replace("/o/x", "/o/original")}`; + expect( + rewriteAssetLinks({ + markdown: `Protocol: {{[[pdf]]: ${published}}}`, + assets: [ + { + sourceLocator: published, + url: MIRRORED, + sourcePath: "protocol.pdf", + }, + ], + }), + ).toBe(`Protocol: {{[[pdf]]: ${MIRRORED}}}`); + }); + + it("rewrites an Obsidian wikilink embed", () => { + expect( + rewriteAssetLinks({ + markdown: `![[attachments/scan.png]]`, + assets: [{ sourceLocator: "attachments/scan.png", url: MIRRORED }], + }), + ).toBe(`![](${MIRRORED})`); + }); + + it("leaves a page reference alone, since a page name is not a recorded locator", () => { + const markdown = `Supported by [[EVD]] - Rasch & Born 2013`; + expect( + rewriteAssetLinks({ + markdown, + assets: [{ sourceLocator: "vault/diagram.png", url: MIRRORED }], + }), + ).toBe(markdown); + }); + + it("rewrites every occurrence of a locator the content repeats", () => { + expect( + rewriteAssetLinks({ + markdown: `![](a.png)\n\nand again ![](a.png)`, + assets: [{ sourceLocator: "a.png", url: MIRRORED }], + }), + ).toBe(`![](${MIRRORED})\n\nand again ![](${MIRRORED})`); + }); + + // A Roam-origin non-media asset arrives as a bare storage URL, and Roam renders a bare + // URL using the URL itself as the link text. The recorded name is the only place a + // reader ever learns what the file is called. + it("labels a non-media link with the recorded name rather than the storage URL", () => { + const published = `${MIRRORED.replace("/o/x", "/o/GVfB6XBcMR")}`; + const result = rewriteAssetLinks({ + markdown: `Protocol: ${published}`, + assets: [ + { sourceLocator: published, url: MIRRORED, sourcePath: "report.docx" }, + ], + }); + + expect(result).toBe(`Protocol: [report.docx](${MIRRORED})`); + expect(result).not.toContain(published); + }); + + it("prefers display text the source already wrote over the recorded name", () => { + expect( + rewriteAssetLinks({ + markdown: `[the protocol](notes/report.docx)`, + assets: [ + { + sourceLocator: "notes/report.docx", + url: MIRRORED, + sourcePath: "report.docx", + }, + ], + }), + ).toBe(`[the protocol](${MIRRORED})`); + }); + + it("falls back to the locator's own name when nothing was recorded", () => { + expect( + rewriteAssetLinks({ + markdown: `[](notes/report.docx)`, + assets: [{ sourceLocator: "notes/report.docx", url: MIRRORED }], + }), + ).toBe(`[report.docx](${MIRRORED})`); + }); + + it("matches a URL a sentence ended on, which the publisher recorded without its period", () => { + // `findAssetReferences` strips trailing punctuation before writing `filepath`, so a + // lookup that did not would leave the page pointing at the origin graph's storage. + const asset = `https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2FMAPLab%2Fx.png?alt=media&token=abc`; + + expect( + rewriteAssetLinks({ + markdown: `Protocol: ${asset}. Next sentence.`, + assets: [{ sourceLocator: asset, url: MIRRORED }], + }), + ).toBe(`Protocol: ![](${MIRRORED}). Next sentence.`); + }); + + it("keeps the sentence's punctuation outside the link it followed", () => { + const asset = + "https://firebasestorage.googleapis.com/v0/b/f/o/report?alt=media"; + + expect( + rewriteAssetLinks({ + markdown: `See ${asset}, then stop.`, + assets: [ + { sourceLocator: asset, url: MIRRORED, sourcePath: "report.docx" }, + ], + }), + ).toBe(`See [report.docx](${MIRRORED}), then stop.`); + }); + + it("rewrites an image nested in a link without swallowing the outer bracket", () => { + expect( + rewriteAssetLinks({ + markdown: `[![diagram](vault/d.png)](https://source.example)`, + assets: [{ sourceLocator: "vault/d.png", url: MIRRORED }], + }), + ).toBe(`[![diagram](${MIRRORED})](https://source.example)`); + }); + + it("matches a percent-encoded locator against the decoded path Obsidian recorded", () => { + // The note holds the encoded form; `metadataCache` gives the publisher the decoded + // one, so every vault path with a space in it arrives spelled two ways. + expect( + rewriteAssetLinks({ + markdown: `![](my%20folder/d.png)`, + assets: [{ sourceLocator: "my folder/d.png", url: MIRRORED }], + }), + ).toBe(`![](${MIRRORED})`); + }); + + it("embeds a type the extension list does not know, because the markdown embedded it", () => { + expect( + rewriteAssetLinks({ + markdown: `![[photo.avif]]`, + assets: [{ sourceLocator: "photo.avif", url: MIRRORED }], + }), + ).toBe(`![](${MIRRORED})`); + }); + + it("embeds a storage URL carrying no extension at all", () => { + const asset = + "https://firebasestorage.googleapis.com/v0/b/f/o/abc?alt=media&token=1"; + + expect( + rewriteAssetLinks({ + markdown: `![](${asset})`, + assets: [{ sourceLocator: asset, url: MIRRORED }], + }), + ).toBe(`![](${MIRRORED})`); + }); + + it("keeps the visible text of a deliberate link to an image", () => { + // Roam renders no alt text, so embedding this would delete the words "Figure 3". + expect( + rewriteAssetLinks({ + markdown: `[Figure 3](vault/d.png)`, + assets: [{ sourceLocator: "vault/d.png", url: MIRRORED }], + }), + ).toBe(`[Figure 3](${MIRRORED})`); + }); + + it("labels a file whose recorded name is empty from its locator", () => { + expect( + rewriteAssetLinks({ + markdown: `[](notes/report.docx)`, + assets: [ + { sourceLocator: "notes/report.docx", url: MIRRORED, sourcePath: "" }, + ], + }), + ).toBe(`[report.docx](${MIRRORED})`); + }); + + it("treats a locator whose extension names a prototype member as an unknown type", () => { + // Not the assertion it looks like: the point is that the literal string `undefined` + // never reaches the page. An unknown extension is a file, like any other. + expect( + rewriteAssetLinks({ + markdown: `![](vault/odd.constructor)`, + assets: [{ sourceLocator: "vault/odd.constructor", url: MIRRORED }], + }), + ).toBe(`[odd.constructor](${MIRRORED})`); + }); + + it("keeps the type a Roam media embed declared, which its storage URL cannot show", () => { + const asset = + "https://firebasestorage.googleapis.com/v0/b/f/o/abc?alt=media&token=1"; + + expect( + rewriteAssetLinks({ + markdown: `{{[[pdf]]: ${asset}}}`, + assets: [{ sourceLocator: asset, url: MIRRORED }], + }), + ).toBe(`{{[[pdf]]: ${MIRRORED}}}`); + expect( + rewriteAssetLinks({ + markdown: `{{audio: ${asset}}}`, + assets: [{ sourceLocator: asset, url: MIRRORED }], + }), + ).toBe(`{{[[audio]]: ${MIRRORED}}}`); + }); + + it("keeps a non-media wikilink embed a labelled link, so its name survives", () => { + expect( + rewriteAssetLinks({ + markdown: `![[notes/report.docx]]`, + assets: [{ sourceLocator: "notes/report.docx", url: MIRRORED }], + }), + ).toBe(`[report.docx](${MIRRORED})`); + }); + + it("returns the markdown untouched when the node has no assets", () => { + const markdown = `![](a.png) and [[EVD]]`; + expect(rewriteAssetLinks({ markdown, assets: [] })).toBe(markdown); + }); +}); + +describe("the cross-app contract fixtures round-trip", () => { + const mirrorAll = (node: typeof roamOriginNodeExample) => + (node.assets ?? []).map((asset) => ({ + sourceLocator: asset.sourceRef, + url: `${MIRRORED}#${asset.contentHash.slice(0, 8)}`, + sourcePath: asset.sourcePath, + })); + + it("resolves the Roam fixture's stored asset and leaves its unresolvable one in place", () => { + const markdown = roamOriginNodeExample.content.full?.value ?? ""; + const [stored] = roamOriginNodeExample.assets ?? []; + const result = rewriteAssetLinks({ + markdown, + assets: mirrorAll(roamOriginNodeExample), + }); + + expect(result).not.toContain(stored?.sourceRef); + expect(result).toContain( + `![](${MIRRORED}#${stored?.contentHash.slice(0, 8)})`, + ); + // The fixture's second asset is deliberately absent from `assets`: its bytes were + // never stored, so the locator stays exactly as published. That is the degradation path. + expect(result).toContain( + "{{[[pdf]]: https://firebasestorage.googleapis.com", + ); + expect(result).toContain("[[EVD]]"); + }); + + it("resolves the Obsidian fixture's wikilink embed", () => { + const markdown = obsidianOriginNodeExample.content.full?.value ?? ""; + const [asset] = obsidianOriginNodeExample.assets ?? []; + const result = rewriteAssetLinks({ + markdown, + assets: mirrorAll(obsidianOriginNodeExample), + }); + + expect(result).not.toContain(`![[${asset?.sourceRef}]]`); + expect(result).toContain( + `![](${MIRRORED}#${asset?.contentHash.slice(0, 8)})`, + ); + }); +}); diff --git a/apps/roam/src/utils/findAssetReferences.ts b/apps/roam/src/utils/findAssetReferences.ts index 7d07245ae..ab181f436 100644 --- a/apps/roam/src/utils/findAssetReferences.ts +++ b/apps/roam/src/utils/findAssetReferences.ts @@ -37,8 +37,14 @@ const ASSET_REFERENCE_PATTERN = new RegExp( "g", ); -/** Punctuation that ends a sentence rather than the URL it follows. */ -const TRAILING_PUNCTUATION = /[.,;:!?]+$/; +/** + * Punctuation that ends a sentence rather than the URL it follows. + * + * Exported because `rewriteAssetLinks` has to strip exactly what the publisher stripped + * before recording `filepath`. Two copies could drift, and every asset mentioned at the + * end of a sentence would stop resolving with no test failing. + */ +export const TRAILING_PUNCTUATION = /[.,;:!?]+$/; /** * The object this Storage URL addresses, decoded, as path segments. Empty when the URL diff --git a/apps/roam/src/utils/rewriteAssetLinks.ts b/apps/roam/src/utils/rewriteAssetLinks.ts new file mode 100644 index 000000000..374651304 --- /dev/null +++ b/apps/roam/src/utils/rewriteAssetLinks.ts @@ -0,0 +1,395 @@ +/** + * Rewrites the asset links in an imported node's markdown to point at this graph's own + * copies. + * + * Resolution is by recorded row, never by origin. Each `FileReference` records the locator + * exactly as the publishing platform's content expressed it, so a locator is rewritten when + * a row matches it and left alone when none does. That one rule covers every case: a + * genuinely external link has no row, and so does an asset whose bytes could not be + * copied, which is why leaving the locator untouched is also the degradation path. Nothing + * here inspects a locator's shape: a Roam-origin locator is a storage URL and an + * Obsidian-origin one is a vault path, and this code never needs to know which it has. + */ + +import mimeDb from "mime-db"; +import { TRAILING_PUNCTUATION } from "./findAssetReferences"; + +/** How Roam has to be told to render an asset, which is not the same for every type. */ +type AssetKind = "image" | "pdf" | "audio" | "video" | "file"; + +export type ResolvedAsset = { + /** The locator as the published markdown holds it, from `FileReference.filepath`. */ + sourceLocator: string; + /** Where this graph's own copy lives. */ + url: string; + /** The name the reference records, from `FileReference.source_path`. */ + sourcePath?: string | null; + mimetype?: string; +}; + +/** + * The kind Roam renders a MIME type as. + * + * Roam's own rule, verified by uploading one file per type and reading back what + * `file.upload` returned: it branches on the first part of the type. Every `image/*` + * embeds, `audio/*` and `video/*` get their players, `application/pdf` gets the PDF + * viewer, and everything else is written as a bare URL. + * + * Reproduced rather than curated, so an imported asset renders the way the same file + * would if it had been uploaded into this graph directly. That includes the awkward + * cases: `image/vnd.adobe.photoshop` embeds and may show a broken image, exactly as it + * does for a native upload. + * + * Normalised first: the value comes from a `FileReference` row, not from `mime-db`, so + * its case and parameters are not ours to assume. + */ +const kindForMimetype = ( + mimetype: string | undefined, +): AssetKind | undefined => { + const [type, subtype] = + mimetype?.toLowerCase().split(";")[0]?.trim().split("/") ?? []; + if (type === "image") return "image"; + if (type === "audio") return "audio"; + if (type === "video") return "video"; + // Scoped to the one type verified above. + if (type === "application" && subtype === "pdf") return "pdf"; + return undefined; +}; + +/** + * Extension to kind, indexed from `mime-db` rather than maintained here. + * + * An extension can be claimed by several types, and `mime-db` states no preference: + * `.mp4` is both `application/mp4` and `video/mp4`. A type that renders as nothing never + * wins, which settles every case that matters. Where two rendering types claim one + * extension the first indexed wins, and since both embed, the cost is the wrong player + * rather than a broken link. + * + * Prototype-free, and read through `Object.hasOwn`: a locator ending in `.constructor` + * would otherwise look up a function, fail every `case` in `render`, and write the + * literal string `undefined` into the user's page in place of their content. + */ +const EXTENSION_KINDS: Record = Object.entries( + mimeDb, +).reduce( + (map, [mimetype, definition]) => { + const kind = kindForMimetype(mimetype); + if (kind === undefined) return map; + for (const extension of definition.extensions ?? []) + if (map[extension] === undefined) map[extension] = kind; + return map; + }, + Object.create(null) as Record, +); + +const kindForExtension = (extension: string): AssetKind | undefined => + Object.hasOwn(EXTENSION_KINDS, extension) + ? EXTENSION_KINDS[extension] + : undefined; + +const extensionOf = (path: string): string => { + // A URL's query string is not part of its name: Roam storage URLs end in `?alt=media`. + const leaf = path.split(/[?#]/)[0]?.split("/").pop() ?? ""; + const dot = leaf.lastIndexOf("."); + return dot > 0 ? leaf.slice(dot + 1).toLowerCase() : ""; +}; + +/** + * How the source markdown wrote the reference: `embed` is `![...]` in any of its + * spellings, `link` is `[...]`, and a bare URL is neither. + */ +type ReferenceForm = "embed" | "link" | "bare"; + +/** How the reference was written, and what the source said it was, where it said so. */ +type ReferenceContext = { + form: ReferenceForm; + /** From `{{[[pdf]]: url}}` and its bracket-less spelling, which name the type. */ + declaredKind?: AssetKind; + /** + * What followed the pipe in `![[locator|…]]`. A width on an image and a label on + * anything else, so only `render` can spend it, once the kind is known. + */ + embedAlias?: string; +}; + +/** + * What kind of asset this is, in decreasing order of how much the source committed to: + * the recorded MIME type, the kind the markdown named outright, the extension on either + * name, and finally the form it was written in. An unrecognised type is a `file`, which + * renders as a labelled link and is the one form that works for anything. + * + * The form ranks last as the weakest evidence, deciding only where nothing else can: an + * extension-less locator embedded as `![](url)`. Roam keeps the extension after the uid on + * nearly every upload, so that is rare, and with nothing populating `mimetype` the + * extension does the work in practice. + */ +const kindOf = ( + { sourcePath, sourceLocator, mimetype }: ResolvedAsset, + { form, declaredKind }: ReferenceContext, +): AssetKind => { + const byType = kindForMimetype(mimetype); + if (byType) return byType; + + // `{{[[pdf]]: url}}` is not a guess to be improved on: the source said what this is. + if (declaredKind) return declaredKind; + + const named = + kindForExtension(extensionOf(sourcePath ?? "")) ?? + kindForExtension(extensionOf(sourceLocator)); + if (named) return named; + + // Only where nothing names an extension. An embed of `report.docx` said "embed" but + // never said "image", and treating it as one would assert a type the source contradicts + // and drop the filename `labelFor` exists to show. An extension-less locator has no such + // claim to contradict, and there `![...]` is the only evidence available. + const unnamed = !extensionOf(sourcePath ?? "") && !extensionOf(sourceLocator); + return form === "embed" && unnamed ? "image" : "file"; +}; + +/** + * Brackets end a markdown label early, and `Paper [draft].pdf` is an ordinary attachment + * name. Stripping is not a fallback for escaping, it is the only option: Roam honours no + * escape, and `\[` was verified in a graph to break exactly as a bare `[` does. + * + * Applied only where a bracket would newly break something: a label we invent from a + * filename, and a wikilink alias we translate into markdown link syntax. Image alt text + * is passed through untouched, because it is the author's own markdown and renders the + * way it always did. + */ +const stripLabelBrackets = (label: string): string => + label.replace(/[[\]]/g, ""); + +/** + * The label for an asset Roam renders as a link rather than as content. + * + * `file.upload` returns a bare URL for anything that is not an image, PDF, audio or + * video, and a bare URL in a block renders as a link whose visible text is the URL: a + * hundred characters of storage uid telling the reader nothing. This label is the only + * place a user sees what an imported file is called. + * + * One case has nothing better to offer: a bare storage URL whose row records no name, + * where the label falls back to the uid leaf, shorter than the URL and no more + * informative. Only a recorded name fixes that; the type ranks decide how an asset + * renders, not what it is called. + */ +const labelFor = ({ + asset, + linkText, +}: { + asset: ResolvedAsset; + linkText: string; +}): string => { + const trimmed = linkText.trim(); + if (trimmed) return trimmed; + // `||`, not `??`: an empty recorded name is no name, and falling through to the locator + // is what the chain is for. With `??` the label would come out as the storage URL, + // which is exactly what this function exists to avoid. + const leaf = (asset.sourcePath || asset.sourceLocator).split(/[?#]/)[0] ?? ""; + return leaf.split("/").pop() || asset.url; +}; + +const render = ({ + asset, + linkText, + context, +}: { + asset: ResolvedAsset; + linkText: string; + context: ReferenceContext; +}): string => { + // A note that wrote `[Figure 3](image)` chose a link over an embed, and no Roam media + // embed carries text, so embedding any of them would delete the only words the reader + // sees. Kind is irrelevant here: an embed, a bare URL, and a link with nothing in its + // brackets all have no text to lose, and every other link does. + if (context.form === "link" && linkText.trim()) + return `[${stripLabelBrackets(linkText.trim())}](${asset.url})`; + + switch (kindOf(asset, context)) { + case "image": + return `![${linkText.trim()}](${asset.url})`; + case "pdf": + return `{{[[pdf]]: ${asset.url}}}`; + case "audio": + return `{{[[audio]]: ${asset.url}}}`; + case "video": + return `{{[[video]]: ${asset.url}}}`; + case "file": + default: + // Only here. On an image, audio or video the pipe was a width, and the branches + // above ignore it rather than printing `![300](…)`. + return `[${stripLabelBrackets( + labelFor({ asset, linkText: linkText || context.embedAlias || "" }), + )}](${asset.url})`; + } +}; + +/** A URL as it sits in content, stopping at the punctuation that encloses it. */ +const URL_PATTERN = String.raw`https?://[^\s<>()\[\]{}"']+`; + +/** + * The link forms an imported node's markdown can express an asset in, matched in one pass + * so that a locator inside an embed is never also treated as a bare reference. + * + * Ordered. The media-embed branches precede the wikilink ones, or `{{[[pdf]]: url}}` + * would be read as a page reference to `pdf` and its URL left behind as a bare locator. + * Roam writes a stored PDF in exactly that form, so this is the shape a Roam-origin + * asset arrives in, not a hypothetical one. + * + * The wikilink branches carry Obsidian-origin notes. A Roam page reference is written the + * same way, so it is matched too, but it can never resolve: a page name is not a recorded + * locator, and an unmatched locator is left exactly as it was. + */ +const LINK_PATTERN = new RegExp( + [ + String.raw`!\[([^\]]*)\]\((<[^>]*>|[^)\s]+)(?:\s+"[^"]*")?\)`, // ![alt](locator) + // No `[` in the label, so `[![alt](image)](link)` cannot match here from the outer + // bracket: the branch fails, the scan advances one character, and the image branch + // takes the inner embed as it should. Alternation is tried per position, so ordering + // the image branch first is not enough on its own. + String.raw`\[([^\]\[]*)\]\((<[^>]*>|[^)\s]+)(?:\s+"[^"]*")?\)`, // [label](locator) + // The media keyword is captured, not discarded: it is the source stating the type, + // and it is the only statement available for a storage uid with no extension. + String.raw`\{\{\[\[(pdf|audio|video)\]\]:\s*(${URL_PATTERN})\s*\}\}`, // {{[[pdf]]: url}} + String.raw`\{\{(pdf|audio|video):\s*(${URL_PATTERN})\s*\}\}`, // {{pdf: url}} + // The embed's pipe means two things depending on what it embeds: a width for an + // image (`![[x.png|300]]`) and a label for anything else (`![[a.pdf|the paper]]`). + // It is captured either way and `render` decides, because only the resolved kind + // says which one this is. + String.raw`!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]`, // ![[locator]] or ![[locator|300]] + String.raw`\[\[([^\]|]+)(?:\|([^\]]*))?\]\]`, // [[locator]] or [[locator|label]] + // The bracketed form is an autolink. Matching it whole, brackets included, is what + // lets them go away with the rest of the match: capturing only the URL inside would + // rewrite the middle and leave `<` and `>` wrapped around the result. + `(<${URL_PATTERN}>|${URL_PATTERN})`, // a bare URL, rewritten only when a row matches it + ].join("|"), + "g", +); + +/** + * The forms of a locator that could match a recorded reference, in decreasing fidelity. + * + * Two mismatches are known and neither is the publisher's to fix. Trailing punctuation is + * stripped at publication but is inside the locator here. And a markdown link percent- + * encodes what a vault path spells plainly. Obsidian records `my folder/d.png` from + * `metadataCache`, while the note itself holds `my%20folder/d.png`, so any vault path + * with a space in it needs the decoded form to match. + */ +const lookupCandidates = (locator: string): string[] => { + const candidates = [locator]; + const withoutPunctuation = locator.replace(TRAILING_PUNCTUATION, ""); + if (withoutPunctuation !== locator) candidates.push(withoutPunctuation); + for (const candidate of [...candidates]) { + try { + const decoded = decodeURIComponent(candidate); + if (decoded !== candidate) candidates.push(decoded); + } catch { + // A stray `%` is not an encoding, and the raw form is already a candidate. + } + } + return candidates; +}; + +// One capture group per branch, in the order the pattern lists them. +const parseMatch = ( + groups: (string | undefined)[], +): + | { + locator: string; + form: ReferenceForm; + declaredKind?: AssetKind; + embedAlias?: string; + linkText: string; + } + | undefined => { + const [ + imageAlt, + imageLocator, + linkLabel, + linkLocator, + bracketedMediaKind, + bracketedMediaLocator, + mediaKind, + mediaLocator, + embedLocator, + embedAlias, + wikiLocator, + wikiLabel, + bareLocator, + ] = groups; + const bracketed = + imageLocator ?? + linkLocator ?? + bracketedMediaLocator ?? + mediaLocator ?? + embedLocator ?? + wikiLocator ?? + bareLocator; + if (bracketed === undefined) return undefined; + const locator = + bracketed.startsWith("<") && bracketed.endsWith(">") + ? bracketed.slice(1, -1) + : bracketed; + + const form: ReferenceForm = + imageLocator !== undefined || + bracketedMediaLocator !== undefined || + mediaLocator !== undefined || + embedLocator !== undefined + ? "embed" + : bareLocator !== undefined + ? "bare" + : "link"; + + return { + locator, + form, + declaredKind: (bracketedMediaKind ?? mediaKind) as AssetKind | undefined, + embedAlias, + // A wikilink embed carries no separate text, so its label comes from the asset. + linkText: imageLocator ? (imageAlt ?? "") : (linkLabel ?? wikiLabel ?? ""), + }; +}; + +export const rewriteAssetLinks = ({ + markdown, + assets, +}: { + markdown: string; + assets: ResolvedAsset[]; +}): string => { + if (!assets.length) return markdown; + const byLocator = new Map( + assets.map((asset) => [asset.sourceLocator, asset]), + ); + + return markdown.replace( + LINK_PATTERN, + (match: string, ...groups: (string | undefined)[]) => { + const parsed = parseMatch(groups); + if (!parsed) return match; + const { locator, form, declaredKind, embedAlias, linkText } = parsed; + + const candidates = lookupCandidates(locator); + const matched = candidates.find((candidate) => byLocator.has(candidate)); + if (matched === undefined) return match; + const asset = byLocator.get(matched); + if (!asset) return match; + + const rewritten = render({ + asset, + linkText, + context: { form, declaredKind, embedAlias }, + }); + + // Punctuation only comes back on a bare URL, where it was the sentence's rather + // than the link's. Inside `![](…)` or `{{[[pdf]]: …}}` the locator is delimited + // already, so a trailing character there was part of the URL the publisher chose + // to record without. Putting it back would leave a stray mark beside the embed. + const trailing = + form === "bare" && matched !== locator + ? (locator.match(TRAILING_PUNCTUATION)?.[0] ?? "") + : ""; + return `${rewritten}${trailing}`; + }, + ); +}; diff --git a/packages/database/package.json b/packages/database/package.json index 879730574..0d3b8941d 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -12,7 +12,8 @@ }, "./dbTypes": "./src/dbTypes.ts", "./inputTypes": "./src/inputTypes.ts", - "./crossAppContracts": "./src/crossAppContracts.ts" + "./crossAppContracts": "./src/crossAppContracts.ts", + "./crossAppNodeContract.example": "./src/crossAppNodeContract.example.ts" }, "typesVersions": { "*": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9832779e..56cb38c4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: lodash.isequal: specifier: ^4.5.0 version: 4.5.0 + mime-db: + specifier: ^1.54.0 + version: 1.54.0 minisearch: specifier: ^7.2.0 version: 7.2.0 @@ -384,6 +387,9 @@ importers: '@types/file-saver': specifier: 2.0.5 version: 2.0.5 + '@types/mime-db': + specifier: ^1.43.6 + version: 1.43.6 '@types/nanoid': specifier: 2.0.0 version: 2.0.0 @@ -5071,6 +5077,9 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + '@types/mime-db@1.43.6': + resolution: {integrity: sha512-r2cqxAt/Eo5yWBOQie1lyM1JZFCiORa5xtLlhSZI0w8RJggBPKw8c4g/fgQCzWydaDR5bL4imnmix2d1n52iBw==} + '@types/mime-types@3.0.1': resolution: {integrity: sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==} @@ -16626,6 +16635,8 @@ snapshots: '@types/mdx@2.0.13': {} + '@types/mime-db@1.43.6': {} + '@types/mime-types@3.0.1': {} '@types/minimatch@6.0.0':