Skip to content

Commit b977291

Browse files
committed
[ENG-1858] Materialize Obsidian-origin markdown into Roam
1 parent 39dc8cb commit b977291

4 files changed

Lines changed: 497 additions & 9 deletions

File tree

apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,15 @@ describe("imported source identity metadata", () => {
7676
).toBeUndefined();
7777
});
7878

79-
it("writes the source RID and modified time while preserving sibling metadata", () => {
79+
it("writes the source RID and modified time while preserving sibling metadata", async () => {
8080
propsByUid.set(PAGE_UID, {
8181
[DISCOURSE_GRAPH_PROP_NAME]: {
8282
"relation-migration": { relationUid: 1718000000000 },
8383
},
8484
"other-extension": { enabled: true },
8585
});
8686

87-
writeImportedSourceIdentity({
87+
await writeImportedSourceIdentity({
8888
pageUid: PAGE_UID,
8989
sourceModifiedAt: SOURCE_MODIFIED_AT,
9090
sourceNodeRid: SOURCE_NODE_RID,
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { contentTypes } from "@repo/content-model";
2+
import type { CrossAppNode } from "@repo/database/crossAppContracts";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { materializeObsidianNode } from "~/utils/materializeObsidianNode";
5+
6+
const mocks = vi.hoisted(() => ({
7+
deleteBlock: vi.fn(),
8+
findImportedNodeUidBySourceRid: vi.fn(),
9+
getShallowTreeByParentUid: vi.fn(),
10+
writeImportedSourceIdentity: vi.fn(),
11+
}));
12+
13+
vi.mock("roamjs-components/queries/getShallowTreeByParentUid", () => ({
14+
default: mocks.getShallowTreeByParentUid,
15+
}));
16+
17+
vi.mock("roamjs-components/writes/deleteBlock", () => ({
18+
default: mocks.deleteBlock,
19+
}));
20+
21+
vi.mock("~/utils/importedSourceIdentity", () => ({
22+
findImportedNodeUidBySourceRid: mocks.findImportedNodeUidBySourceRid,
23+
writeImportedSourceIdentity: mocks.writeImportedSourceIdentity,
24+
}));
25+
26+
const SOURCE_NODE_RID = "orn:obsidian.note:vault-a/node-1";
27+
const SOURCE_MODIFIED_AT = "2026-06-14T15:00:00.000Z";
28+
const NEW_PAGE_UID = "new-page-uid";
29+
const EXISTING_PAGE_UID = "existing-page-uid";
30+
const MARKDOWN = "# REM sleep correlates with recall\n\nUpdated evidence.";
31+
32+
const node: CrossAppNode = {
33+
localId: "node-1",
34+
nodeType: { localId: "evidence" },
35+
content: {
36+
direct: { value: "EVD - REM sleep and recall" },
37+
full: {
38+
contentType: contentTypes.obsidianMarkdown,
39+
value: MARKDOWN,
40+
},
41+
},
42+
createdAt: new Date("2026-06-14T10:30:00.000Z"),
43+
modifiedAt: new Date(SOURCE_MODIFIED_AT),
44+
author: { localId: "author" },
45+
};
46+
47+
const pageFromMarkdown = vi.fn();
48+
const blockFromMarkdown = vi.fn();
49+
const deletePage = vi.fn();
50+
const updatePage = vi.fn();
51+
52+
const setRoamAlphaApi = (): void => {
53+
(globalThis as { window: unknown }).window = {
54+
roamAlphaAPI: {
55+
data: {
56+
block: { fromMarkdown: blockFromMarkdown },
57+
page: {
58+
delete: deletePage,
59+
fromMarkdown: pageFromMarkdown,
60+
update: updatePage,
61+
},
62+
},
63+
util: { generateUID: () => NEW_PAGE_UID },
64+
},
65+
};
66+
};
67+
68+
beforeEach(() => {
69+
vi.clearAllMocks();
70+
mocks.deleteBlock.mockResolvedValue(undefined);
71+
mocks.getShallowTreeByParentUid.mockReturnValue([]);
72+
pageFromMarkdown.mockResolvedValue({ uid: NEW_PAGE_UID });
73+
blockFromMarkdown.mockResolvedValue({ uids: [] });
74+
deletePage.mockResolvedValue(undefined);
75+
updatePage.mockResolvedValue(undefined);
76+
setRoamAlphaApi();
77+
});
78+
79+
describe("materializeObsidianNode", () => {
80+
it("creates a Roam page from Obsidian markdown and stores source identity", async () => {
81+
mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null);
82+
83+
await expect(
84+
materializeObsidianNode({
85+
node,
86+
sourceModifiedAt: SOURCE_MODIFIED_AT,
87+
sourceNodeRid: SOURCE_NODE_RID,
88+
}),
89+
).resolves.toEqual({
90+
success: true,
91+
action: "created",
92+
pageUid: NEW_PAGE_UID,
93+
sourceModifiedAt: SOURCE_MODIFIED_AT,
94+
sourceNodeRid: SOURCE_NODE_RID,
95+
});
96+
97+
expect(pageFromMarkdown).toHaveBeenCalledWith({
98+
page: {
99+
title: "EVD - REM sleep and recall",
100+
uid: NEW_PAGE_UID,
101+
},
102+
"markdown-string": MARKDOWN,
103+
});
104+
expect(mocks.writeImportedSourceIdentity).toHaveBeenCalledWith({
105+
pageUid: NEW_PAGE_UID,
106+
sourceModifiedAt: SOURCE_MODIFIED_AT,
107+
sourceNodeRid: SOURCE_NODE_RID,
108+
});
109+
});
110+
111+
it("replaces the existing imported page instead of creating a duplicate", async () => {
112+
mocks.findImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID);
113+
mocks.getShallowTreeByParentUid.mockReturnValue([
114+
{ uid: "old-child-1", text: "Old content" },
115+
{ uid: "old-child-2", text: "More old content" },
116+
]);
117+
118+
await expect(
119+
materializeObsidianNode({
120+
node,
121+
sourceModifiedAt: SOURCE_MODIFIED_AT,
122+
sourceNodeRid: SOURCE_NODE_RID,
123+
}),
124+
).resolves.toMatchObject({
125+
success: true,
126+
action: "updated",
127+
pageUid: EXISTING_PAGE_UID,
128+
});
129+
130+
expect(pageFromMarkdown).not.toHaveBeenCalled();
131+
expect(updatePage).toHaveBeenCalledWith({
132+
page: {
133+
title: "EVD - REM sleep and recall",
134+
uid: EXISTING_PAGE_UID,
135+
},
136+
"merge-pages": false,
137+
});
138+
expect(mocks.deleteBlock).toHaveBeenCalledTimes(2);
139+
expect(mocks.deleteBlock).toHaveBeenCalledWith("old-child-1");
140+
expect(mocks.deleteBlock).toHaveBeenCalledWith("old-child-2");
141+
expect(blockFromMarkdown).toHaveBeenCalledWith({
142+
location: { "parent-uid": EXISTING_PAGE_UID, order: "last" },
143+
"markdown-string": MARKDOWN,
144+
});
145+
expect(mocks.writeImportedSourceIdentity).toHaveBeenCalledWith({
146+
pageUid: EXISTING_PAGE_UID,
147+
sourceModifiedAt: SOURCE_MODIFIED_AT,
148+
sourceNodeRid: SOURCE_NODE_RID,
149+
});
150+
});
151+
152+
it("returns the source identity and failed stage when replacement fails", async () => {
153+
mocks.findImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID);
154+
blockFromMarkdown.mockRejectedValue(new Error("markdown parser failed"));
155+
156+
await expect(
157+
materializeObsidianNode({
158+
node,
159+
sourceModifiedAt: SOURCE_MODIFIED_AT,
160+
sourceNodeRid: SOURCE_NODE_RID,
161+
}),
162+
).resolves.toEqual({
163+
success: false,
164+
pageUid: EXISTING_PAGE_UID,
165+
sourceModifiedAt: SOURCE_MODIFIED_AT,
166+
sourceNodeRid: SOURCE_NODE_RID,
167+
error: {
168+
message: `Failed to replace Roam content for '${SOURCE_NODE_RID}': markdown parser failed`,
169+
stage: "replace-page-content",
170+
},
171+
});
172+
expect(mocks.writeImportedSourceIdentity).not.toHaveBeenCalled();
173+
expect(mocks.deleteBlock).not.toHaveBeenCalled();
174+
});
175+
176+
it("removes a new page if its source identity cannot be stored", async () => {
177+
mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null);
178+
mocks.writeImportedSourceIdentity.mockRejectedValue(
179+
new Error("props update failed"),
180+
);
181+
182+
const result = await materializeObsidianNode({
183+
node,
184+
sourceModifiedAt: SOURCE_MODIFIED_AT,
185+
sourceNodeRid: SOURCE_NODE_RID,
186+
});
187+
188+
expect(result).toMatchObject({
189+
success: false,
190+
sourceModifiedAt: SOURCE_MODIFIED_AT,
191+
sourceNodeRid: SOURCE_NODE_RID,
192+
error: {
193+
stage: "write-source-identity",
194+
},
195+
});
196+
expect(result).not.toHaveProperty("pageUid");
197+
expect(deletePage).toHaveBeenCalledWith({ page: { uid: NEW_PAGE_UID } });
198+
});
199+
200+
it("rejects non-Obsidian payload identity before writing to Roam", async () => {
201+
const sourceNodeRid = "orn:roam:graph-a/node-1";
202+
203+
await expect(
204+
materializeObsidianNode({
205+
node,
206+
sourceModifiedAt: SOURCE_MODIFIED_AT,
207+
sourceNodeRid,
208+
}),
209+
).resolves.toEqual({
210+
success: false,
211+
sourceModifiedAt: SOURCE_MODIFIED_AT,
212+
sourceNodeRid,
213+
error: {
214+
message: `Source node RID '${sourceNodeRid}' is not Obsidian-origin`,
215+
stage: "validate-input",
216+
},
217+
});
218+
expect(mocks.findImportedNodeUidBySourceRid).not.toHaveBeenCalled();
219+
expect(pageFromMarkdown).not.toHaveBeenCalled();
220+
});
221+
});

apps/roam/src/utils/importedSourceIdentity.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock";
22
import getBlockProps, { type json } from "./getBlockProps";
3-
import setBlockProps from "./setBlockProps";
43

54
export type ImportedSourceIdentity = {
65
sourceModifiedAt: string;
@@ -41,14 +40,21 @@ export const writeImportedSourceIdentity = ({
4140
pageUid: string;
4241
sourceModifiedAt: string;
4342
sourceNodeRid: string;
44-
}): void => {
45-
const existing = getBlockProps(pageUid)[DISCOURSE_GRAPH_PROP_NAME];
43+
}): Promise<void> => {
44+
const props = getBlockProps(pageUid);
45+
const existing = props[DISCOURSE_GRAPH_PROP_NAME];
4646
const discourseGraphProps = isJsonObject(existing) ? existing : {};
4747

48-
setBlockProps(pageUid, {
49-
[DISCOURSE_GRAPH_PROP_NAME]: {
50-
...discourseGraphProps,
51-
[IMPORTED_FROM_PROP_KEY]: { sourceModifiedAt, sourceNodeRid },
48+
return window.roamAlphaAPI.data.block.update({
49+
block: {
50+
uid: pageUid,
51+
props: {
52+
...props,
53+
[DISCOURSE_GRAPH_PROP_NAME]: {
54+
...discourseGraphProps,
55+
[IMPORTED_FROM_PROP_KEY]: { sourceModifiedAt, sourceNodeRid },
56+
},
57+
},
5258
},
5359
});
5460
};

0 commit comments

Comments
 (0)