Skip to content

Commit e3c12d5

Browse files
committed
eng-2217-Rewrite asset links in Obsidian-origin markdown for Roam
1 parent 53d5a0c commit e3c12d5

3 files changed

Lines changed: 661 additions & 1 deletion

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
obsidianOriginNodeExample,
4+
roamOriginNodeExample,
5+
} from "@repo/database/crossAppNodeContract.example";
6+
import { rewriteAssetLinks } from "../rewriteAssetLinks";
7+
8+
const MIRRORED =
9+
"https://firebasestorage.googleapis.com/v0/b/f.appspot.com/o/x?alt=media&token=abc";
10+
const OTHER_MIRRORED =
11+
"https://firebasestorage.googleapis.com/v0/b/f.appspot.com/o/y?alt=media&token=def";
12+
const EXTERNAL = "https://example.org/paper.pdf";
13+
14+
describe("rewriteAssetLinks", () => {
15+
it("rewrites an image embed to this graph's copy", () => {
16+
expect(
17+
rewriteAssetLinks({
18+
markdown: `![](vault/diagram.png)`,
19+
assets: [{ sourceRef: "vault/diagram.png", url: MIRRORED }],
20+
}),
21+
).toBe(`![](${MIRRORED})`);
22+
});
23+
24+
it("keeps the alt text an image already carried", () => {
25+
expect(
26+
rewriteAssetLinks({
27+
markdown: `![the setup](vault/diagram.png)`,
28+
assets: [{ sourceRef: "vault/diagram.png", url: MIRRORED }],
29+
}),
30+
).toBe(`![the setup](${MIRRORED})`);
31+
});
32+
33+
it("writes a non-media asset as a labelled link, not a bare URL", () => {
34+
// A bare URL renders as a link whose visible text is the URL, which tells the reader
35+
// nothing about what the file is.
36+
expect(
37+
rewriteAssetLinks({
38+
markdown: `[](attachments/report.docx)`,
39+
assets: [
40+
{
41+
sourceRef: "attachments/report.docx",
42+
url: MIRRORED,
43+
sourcePath: "attachments/report.docx",
44+
},
45+
],
46+
}),
47+
).toBe(`[report.docx](${MIRRORED})`);
48+
});
49+
50+
it("leaves an external link untouched, because no row matches it", () => {
51+
const markdown = `See [the paper](${EXTERNAL}) and ![](${EXTERNAL})`;
52+
expect(
53+
rewriteAssetLinks({
54+
markdown,
55+
assets: [{ sourceRef: "vault/diagram.png", url: MIRRORED }],
56+
}),
57+
).toBe(markdown);
58+
});
59+
60+
it("uses Roam's own embed syntax for media types", () => {
61+
const assets = [
62+
{ sourceRef: "a.pdf", url: MIRRORED, mimetype: "application/pdf" },
63+
{ sourceRef: "b.mp3", url: OTHER_MIRRORED },
64+
];
65+
expect(
66+
rewriteAssetLinks({ markdown: `![](a.pdf) and ![](b.mp3)`, assets }),
67+
).toBe(`{{[[pdf]]: ${MIRRORED}}} and {{[[audio]]: ${OTHER_MIRRORED}}}`);
68+
});
69+
70+
it("resolves a Roam-origin media embed through its row rather than passing it through", () => {
71+
const published = `${MIRRORED.replace("/o/x", "/o/original")}`;
72+
expect(
73+
rewriteAssetLinks({
74+
markdown: `Protocol: {{[[pdf]]: ${published}}}`,
75+
assets: [
76+
{ sourceRef: published, url: MIRRORED, sourcePath: "protocol.pdf" },
77+
],
78+
}),
79+
).toBe(`Protocol: {{[[pdf]]: ${MIRRORED}}}`);
80+
});
81+
82+
it("rewrites an Obsidian wikilink embed", () => {
83+
expect(
84+
rewriteAssetLinks({
85+
markdown: `![[attachments/scan.png]]`,
86+
assets: [{ sourceRef: "attachments/scan.png", url: MIRRORED }],
87+
}),
88+
).toBe(`![](${MIRRORED})`);
89+
});
90+
91+
it("leaves a page reference alone, since a page name is not a recorded token", () => {
92+
const markdown = `Supported by [[EVD]] - Rasch & Born 2013`;
93+
expect(
94+
rewriteAssetLinks({
95+
markdown,
96+
assets: [{ sourceRef: "vault/diagram.png", url: MIRRORED }],
97+
}),
98+
).toBe(markdown);
99+
});
100+
101+
it("rewrites every occurrence of a token the content repeats", () => {
102+
expect(
103+
rewriteAssetLinks({
104+
markdown: `![](a.png)\n\nand again ![](a.png)`,
105+
assets: [{ sourceRef: "a.png", url: MIRRORED }],
106+
}),
107+
).toBe(`![](${MIRRORED})\n\nand again ![](${MIRRORED})`);
108+
});
109+
110+
// A Roam-origin non-media asset arrives as a bare storage URL, and Roam renders a bare
111+
// URL using the URL itself as the link text. The recorded name is the only place a
112+
// reader ever learns what the file is called.
113+
it("labels a non-media link with the recorded name rather than the storage URL", () => {
114+
const published = `${MIRRORED.replace("/o/x", "/o/GVfB6XBcMR")}`;
115+
const result = rewriteAssetLinks({
116+
markdown: `Protocol: ${published}`,
117+
assets: [
118+
{ sourceRef: published, url: MIRRORED, sourcePath: "report.docx" },
119+
],
120+
});
121+
122+
expect(result).toBe(`Protocol: [report.docx](${MIRRORED})`);
123+
expect(result).not.toContain(published);
124+
});
125+
126+
it("prefers display text the source already wrote over the recorded name", () => {
127+
expect(
128+
rewriteAssetLinks({
129+
markdown: `[the protocol](notes/report.docx)`,
130+
assets: [
131+
{
132+
sourceRef: "notes/report.docx",
133+
url: MIRRORED,
134+
sourcePath: "report.docx",
135+
},
136+
],
137+
}),
138+
).toBe(`[the protocol](${MIRRORED})`);
139+
});
140+
141+
it("falls back to the token's own name when nothing was recorded", () => {
142+
expect(
143+
rewriteAssetLinks({
144+
markdown: `[](notes/report.docx)`,
145+
assets: [{ sourceRef: "notes/report.docx", url: MIRRORED }],
146+
}),
147+
).toBe(`[report.docx](${MIRRORED})`);
148+
});
149+
150+
it("matches a URL a sentence ended on, which the publisher recorded without its period", () => {
151+
// `findAssetReferences` strips trailing punctuation before writing `filepath`, so a
152+
// lookup that did not would leave the page pointing at the origin graph's storage.
153+
const asset = `https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2FMAPLab%2Fx.png?alt=media&token=abc`;
154+
155+
expect(
156+
rewriteAssetLinks({
157+
markdown: `Protocol: ${asset}. Next sentence.`,
158+
assets: [{ sourceRef: asset, url: MIRRORED }],
159+
}),
160+
).toBe(`Protocol: ![](${MIRRORED}). Next sentence.`);
161+
});
162+
163+
it("keeps the sentence's punctuation outside the link it followed", () => {
164+
const asset =
165+
"https://firebasestorage.googleapis.com/v0/b/f/o/report?alt=media";
166+
167+
expect(
168+
rewriteAssetLinks({
169+
markdown: `See ${asset}, then stop.`,
170+
assets: [
171+
{ sourceRef: asset, url: MIRRORED, sourcePath: "report.docx" },
172+
],
173+
}),
174+
).toBe(`See [report.docx](${MIRRORED}), then stop.`);
175+
});
176+
177+
it("rewrites an image nested in a link without swallowing the outer bracket", () => {
178+
expect(
179+
rewriteAssetLinks({
180+
markdown: `[![diagram](vault/d.png)](https://source.example)`,
181+
assets: [{ sourceRef: "vault/d.png", url: MIRRORED }],
182+
}),
183+
).toBe(`[![diagram](${MIRRORED})](https://source.example)`);
184+
});
185+
186+
it("matches a percent-encoded token against the decoded path Obsidian recorded", () => {
187+
// The note holds the encoded form; `metadataCache` gives the publisher the decoded
188+
// one, so every vault path with a space in it arrives spelled two ways.
189+
expect(
190+
rewriteAssetLinks({
191+
markdown: `![](my%20folder/d.png)`,
192+
assets: [{ sourceRef: "my folder/d.png", url: MIRRORED }],
193+
}),
194+
).toBe(`![](${MIRRORED})`);
195+
});
196+
197+
it("embeds a type the extension list does not know, because the markdown embedded it", () => {
198+
expect(
199+
rewriteAssetLinks({
200+
markdown: `![[photo.avif]]`,
201+
assets: [{ sourceRef: "photo.avif", url: MIRRORED }],
202+
}),
203+
).toBe(`![](${MIRRORED})`);
204+
});
205+
206+
it("embeds a storage URL carrying no extension at all", () => {
207+
const asset =
208+
"https://firebasestorage.googleapis.com/v0/b/f/o/abc?alt=media&token=1";
209+
210+
expect(
211+
rewriteAssetLinks({
212+
markdown: `![](${asset})`,
213+
assets: [{ sourceRef: asset, url: MIRRORED }],
214+
}),
215+
).toBe(`![](${MIRRORED})`);
216+
});
217+
218+
it("keeps the visible text of a deliberate link to an image", () => {
219+
// Roam renders no alt text, so embedding this would delete the words "Figure 3".
220+
expect(
221+
rewriteAssetLinks({
222+
markdown: `[Figure 3](vault/d.png)`,
223+
assets: [{ sourceRef: "vault/d.png", url: MIRRORED }],
224+
}),
225+
).toBe(`[Figure 3](${MIRRORED})`);
226+
});
227+
228+
it("labels a file whose recorded name is empty from its token", () => {
229+
expect(
230+
rewriteAssetLinks({
231+
markdown: `[](notes/report.docx)`,
232+
assets: [
233+
{ sourceRef: "notes/report.docx", url: MIRRORED, sourcePath: "" },
234+
],
235+
}),
236+
).toBe(`[report.docx](${MIRRORED})`);
237+
});
238+
239+
it("treats a token whose extension names a prototype member as an unknown type", () => {
240+
// Not the assertion it looks like: the point is that the literal string `undefined`
241+
// never reaches the page. An unknown extension is a file, like any other.
242+
expect(
243+
rewriteAssetLinks({
244+
markdown: `![](vault/odd.constructor)`,
245+
assets: [{ sourceRef: "vault/odd.constructor", url: MIRRORED }],
246+
}),
247+
).toBe(`[odd.constructor](${MIRRORED})`);
248+
});
249+
250+
it("keeps the type a Roam media embed declared, which its storage URL cannot show", () => {
251+
const asset =
252+
"https://firebasestorage.googleapis.com/v0/b/f/o/abc?alt=media&token=1";
253+
254+
expect(
255+
rewriteAssetLinks({
256+
markdown: `{{[[pdf]]: ${asset}}}`,
257+
assets: [{ sourceRef: asset, url: MIRRORED }],
258+
}),
259+
).toBe(`{{[[pdf]]: ${MIRRORED}}}`);
260+
expect(
261+
rewriteAssetLinks({
262+
markdown: `{{audio: ${asset}}}`,
263+
assets: [{ sourceRef: asset, url: MIRRORED }],
264+
}),
265+
).toBe(`{{[[audio]]: ${MIRRORED}}}`);
266+
});
267+
268+
it("keeps a non-media wikilink embed a labelled link, so its name survives", () => {
269+
expect(
270+
rewriteAssetLinks({
271+
markdown: `![[notes/report.docx]]`,
272+
assets: [{ sourceRef: "notes/report.docx", url: MIRRORED }],
273+
}),
274+
).toBe(`[report.docx](${MIRRORED})`);
275+
});
276+
277+
it("returns the markdown untouched when the node has no assets", () => {
278+
const markdown = `![](a.png) and [[EVD]]`;
279+
expect(rewriteAssetLinks({ markdown, assets: [] })).toBe(markdown);
280+
});
281+
});
282+
283+
describe("the cross-app contract fixtures round-trip", () => {
284+
const mirrorAll = (node: typeof roamOriginNodeExample) =>
285+
(node.assets ?? []).map((asset) => ({
286+
sourceRef: asset.sourceRef,
287+
url: `${MIRRORED}#${asset.contentHash.slice(0, 8)}`,
288+
sourcePath: asset.sourcePath,
289+
}));
290+
291+
it("resolves the Roam fixture's stored asset and leaves its unresolvable one in place", () => {
292+
const markdown = roamOriginNodeExample.content.full?.value ?? "";
293+
const [stored] = roamOriginNodeExample.assets ?? [];
294+
const result = rewriteAssetLinks({
295+
markdown,
296+
assets: mirrorAll(roamOriginNodeExample),
297+
});
298+
299+
expect(result).not.toContain(stored?.sourceRef);
300+
expect(result).toContain(
301+
`![](${MIRRORED}#${stored?.contentHash.slice(0, 8)})`,
302+
);
303+
// The fixture's second asset is deliberately absent from `assets`: its bytes were
304+
// never stored, so the token stays exactly as published. That is the degradation path.
305+
expect(result).toContain(
306+
"{{[[pdf]]: https://firebasestorage.googleapis.com",
307+
);
308+
expect(result).toContain("[[EVD]]");
309+
});
310+
311+
it("resolves the Obsidian fixture's wikilink embed", () => {
312+
const markdown = obsidianOriginNodeExample.content.full?.value ?? "";
313+
const [asset] = obsidianOriginNodeExample.assets ?? [];
314+
const result = rewriteAssetLinks({
315+
markdown,
316+
assets: mirrorAll(obsidianOriginNodeExample),
317+
});
318+
319+
expect(result).not.toContain(`![[${asset?.sourceRef}]]`);
320+
expect(result).toContain(
321+
`![](${MIRRORED}#${asset?.contentHash.slice(0, 8)})`,
322+
);
323+
});
324+
});

0 commit comments

Comments
 (0)