diff --git a/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts index 4d161e54c..3c9826eda 100644 --- a/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts +++ b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts @@ -76,7 +76,7 @@ describe("imported source identity metadata", () => { ).toBeUndefined(); }); - it("writes the source RID and modified time while preserving sibling metadata", () => { + it("writes the source RID and modified time while preserving sibling metadata", async () => { propsByUid.set(PAGE_UID, { [DISCOURSE_GRAPH_PROP_NAME]: { "relation-migration": { relationUid: 1718000000000 }, @@ -84,7 +84,7 @@ describe("imported source identity metadata", () => { "other-extension": { enabled: true }, }); - writeImportedSourceIdentity({ + await writeImportedSourceIdentity({ pageUid: PAGE_UID, sourceModifiedAt: SOURCE_MODIFIED_AT, sourceNodeRid: SOURCE_NODE_RID, diff --git a/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts b/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts new file mode 100644 index 000000000..08171d44a --- /dev/null +++ b/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts @@ -0,0 +1,221 @@ +import { contentTypes } from "@repo/content-model"; +import type { CrossAppNode } from "@repo/database/crossAppContracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { materializeObsidianNode } from "~/utils/materializeObsidianNode"; + +const mocks = vi.hoisted(() => ({ + deleteBlock: vi.fn(), + findImportedNodeUidBySourceRid: vi.fn(), + getShallowTreeByParentUid: vi.fn(), + writeImportedSourceIdentity: vi.fn(), +})); + +vi.mock("roamjs-components/queries/getShallowTreeByParentUid", () => ({ + default: mocks.getShallowTreeByParentUid, +})); + +vi.mock("roamjs-components/writes/deleteBlock", () => ({ + default: mocks.deleteBlock, +})); + +vi.mock("~/utils/importedSourceIdentity", () => ({ + findImportedNodeUidBySourceRid: mocks.findImportedNodeUidBySourceRid, + writeImportedSourceIdentity: mocks.writeImportedSourceIdentity, +})); + +const SOURCE_NODE_RID = "orn:obsidian.note:vault-a/node-1"; +const SOURCE_MODIFIED_AT = "2026-06-14T15:00:00.000Z"; +const NEW_PAGE_UID = "new-page-uid"; +const EXISTING_PAGE_UID = "existing-page-uid"; +const MARKDOWN = "# REM sleep correlates with recall\n\nUpdated evidence."; + +const node: CrossAppNode = { + localId: "node-1", + nodeType: { localId: "evidence" }, + content: { + direct: { value: "EVD - REM sleep and recall" }, + full: { + contentType: contentTypes.obsidianMarkdown, + value: MARKDOWN, + }, + }, + createdAt: new Date("2026-06-14T10:30:00.000Z"), + modifiedAt: new Date(SOURCE_MODIFIED_AT), + author: { localId: "author" }, +}; + +const pageFromMarkdown = vi.fn(); +const blockFromMarkdown = vi.fn(); +const deletePage = vi.fn(); +const updatePage = vi.fn(); + +const setRoamAlphaApi = (): void => { + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { + data: { + block: { fromMarkdown: blockFromMarkdown }, + page: { + delete: deletePage, + fromMarkdown: pageFromMarkdown, + update: updatePage, + }, + }, + util: { generateUID: () => NEW_PAGE_UID }, + }, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.deleteBlock.mockResolvedValue(undefined); + mocks.getShallowTreeByParentUid.mockReturnValue([]); + pageFromMarkdown.mockResolvedValue({ uid: NEW_PAGE_UID }); + blockFromMarkdown.mockResolvedValue({ uids: [] }); + deletePage.mockResolvedValue(undefined); + updatePage.mockResolvedValue(undefined); + setRoamAlphaApi(); +}); + +describe("materializeObsidianNode", () => { + it("creates a Roam page from Obsidian markdown and stores source identity", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null); + + await expect( + materializeObsidianNode({ + node, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }), + ).resolves.toEqual({ + success: true, + action: "created", + pageUid: NEW_PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { + title: "EVD - REM sleep and recall", + uid: NEW_PAGE_UID, + }, + "markdown-string": MARKDOWN, + }); + expect(mocks.writeImportedSourceIdentity).toHaveBeenCalledWith({ + pageUid: NEW_PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + }); + + it("replaces the existing imported page instead of creating a duplicate", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID); + mocks.getShallowTreeByParentUid.mockReturnValue([ + { uid: "old-child-1", text: "Old content" }, + { uid: "old-child-2", text: "More old content" }, + ]); + + await expect( + materializeObsidianNode({ + node, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }), + ).resolves.toMatchObject({ + success: true, + action: "updated", + pageUid: EXISTING_PAGE_UID, + }); + + expect(pageFromMarkdown).not.toHaveBeenCalled(); + expect(updatePage).toHaveBeenCalledWith({ + page: { + title: "EVD - REM sleep and recall", + uid: EXISTING_PAGE_UID, + }, + "merge-pages": false, + }); + expect(mocks.deleteBlock).toHaveBeenCalledTimes(2); + expect(mocks.deleteBlock).toHaveBeenCalledWith("old-child-1"); + expect(mocks.deleteBlock).toHaveBeenCalledWith("old-child-2"); + expect(blockFromMarkdown).toHaveBeenCalledWith({ + location: { "parent-uid": EXISTING_PAGE_UID, order: "last" }, + "markdown-string": MARKDOWN, + }); + expect(mocks.writeImportedSourceIdentity).toHaveBeenCalledWith({ + pageUid: EXISTING_PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + }); + + it("returns the source identity and failed stage when replacement fails", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID); + blockFromMarkdown.mockRejectedValue(new Error("markdown parser failed")); + + await expect( + materializeObsidianNode({ + node, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }), + ).resolves.toEqual({ + success: false, + pageUid: EXISTING_PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + error: { + message: `Failed to replace Roam content for '${SOURCE_NODE_RID}': markdown parser failed`, + stage: "replace-page-content", + }, + }); + expect(mocks.writeImportedSourceIdentity).not.toHaveBeenCalled(); + expect(mocks.deleteBlock).not.toHaveBeenCalled(); + }); + + it("removes a new page if its source identity cannot be stored", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null); + mocks.writeImportedSourceIdentity.mockRejectedValue( + new Error("props update failed"), + ); + + const result = await materializeObsidianNode({ + node, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + + expect(result).toMatchObject({ + success: false, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + error: { + stage: "write-source-identity", + }, + }); + expect(result).not.toHaveProperty("pageUid"); + expect(deletePage).toHaveBeenCalledWith({ page: { uid: NEW_PAGE_UID } }); + }); + + it("rejects non-Obsidian payload identity before writing to Roam", async () => { + const sourceNodeRid = "orn:roam:graph-a/node-1"; + + await expect( + materializeObsidianNode({ + node, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid, + }), + ).resolves.toEqual({ + success: false, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid, + error: { + message: `Source node RID '${sourceNodeRid}' is not Obsidian-origin`, + stage: "validate-input", + }, + }); + expect(mocks.findImportedNodeUidBySourceRid).not.toHaveBeenCalled(); + expect(pageFromMarkdown).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/roam/src/utils/importedSourceIdentity.ts b/apps/roam/src/utils/importedSourceIdentity.ts index ae5ba0a0f..841081d98 100644 --- a/apps/roam/src/utils/importedSourceIdentity.ts +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -1,6 +1,5 @@ import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; import getBlockProps, { type json } from "./getBlockProps"; -import setBlockProps from "./setBlockProps"; export type ImportedSourceIdentity = { sourceModifiedAt: string; @@ -41,14 +40,21 @@ export const writeImportedSourceIdentity = ({ pageUid: string; sourceModifiedAt: string; sourceNodeRid: string; -}): void => { - const existing = getBlockProps(pageUid)[DISCOURSE_GRAPH_PROP_NAME]; +}): Promise => { + const props = getBlockProps(pageUid); + const existing = props[DISCOURSE_GRAPH_PROP_NAME]; const discourseGraphProps = isJsonObject(existing) ? existing : {}; - setBlockProps(pageUid, { - [DISCOURSE_GRAPH_PROP_NAME]: { - ...discourseGraphProps, - [IMPORTED_FROM_PROP_KEY]: { sourceModifiedAt, sourceNodeRid }, + return window.roamAlphaAPI.data.block.update({ + block: { + uid: pageUid, + props: { + ...props, + [DISCOURSE_GRAPH_PROP_NAME]: { + ...discourseGraphProps, + [IMPORTED_FROM_PROP_KEY]: { sourceModifiedAt, sourceNodeRid }, + }, + }, }, }); }; diff --git a/apps/roam/src/utils/materializeObsidianNode.ts b/apps/roam/src/utils/materializeObsidianNode.ts new file mode 100644 index 000000000..bcb4c8d64 --- /dev/null +++ b/apps/roam/src/utils/materializeObsidianNode.ts @@ -0,0 +1,261 @@ +import { contentTypes } from "@repo/content-model"; +import type { CrossAppNode } from "@repo/database/crossAppContracts"; +import { ridToSpaceUriAndLocalId } from "@repo/database/lib/rid"; +import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeByParentUid"; +import deleteBlock from "roamjs-components/writes/deleteBlock"; +import { + findImportedNodeUidBySourceRid, + type ImportedSourceIdentity, + writeImportedSourceIdentity, +} from "./importedSourceIdentity"; + +type MaterializationStage = + | "validate-input" + | "find-imported-node" + | "create-page" + | "update-page-title" + | "replace-page-content" + | "write-source-identity"; + +type MaterializationFailure = ImportedSourceIdentity & { + success: false; + pageUid?: string; + error: { + message: string; + stage: MaterializationStage; + }; +}; + +type MaterializationSuccess = ImportedSourceIdentity & { + success: true; + action: "created" | "updated"; + pageUid: string; +}; + +export type MaterializeObsidianNodeResult = + | MaterializationFailure + | MaterializationSuccess; + +type RoamFromMarkdownApi = { + data: { + block: { + fromMarkdown: (args: { + location: { "parent-uid": string; order: "last" }; + "markdown-string": string; + }) => Promise<{ uids: string[] }>; + }; + page: { + delete: (args: { page: { uid: string } }) => Promise; + fromMarkdown: (args: { + page: { title: string; uid: string }; + "markdown-string": string; + }) => Promise<{ uid: string }>; + update: (args: { + page: { title: string; uid: string }; + "merge-pages": false; + }) => Promise; + }; + }; + util: { + generateUID: () => string; + }; +}; + +const getRoamFromMarkdownApi = (): RoamFromMarkdownApi => + window.roamAlphaAPI as unknown as RoamFromMarkdownApi; + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const failure = ({ + error, + identity, + message, + pageUid, + stage, +}: { + error?: unknown; + identity: ImportedSourceIdentity; + message: string; + pageUid?: string; + stage: MaterializationStage; +}): MaterializationFailure => ({ + ...identity, + success: false, + ...(pageUid ? { pageUid } : {}), + error: { + message: error ? `${message}: ${getErrorMessage(error)}` : message, + stage, + }, +}); + +const validateInput = ({ + node, + sourceModifiedAt, + sourceNodeRid, +}: { + node: CrossAppNode; +} & ImportedSourceIdentity): string | undefined => { + if (!sourceNodeRid.trim()) return "Source node RID is required"; + + const { spaceUri } = ridToSpaceUriAndLocalId(sourceNodeRid); + if (!spaceUri.startsWith("obsidian:")) + return `Source node RID '${sourceNodeRid}' is not Obsidian-origin`; + + if (Number.isNaN(Date.parse(sourceModifiedAt))) + return `Source modified time '${sourceModifiedAt}' is invalid`; + + if (!node.content.direct.value.trim()) return "Source node title is required"; + + const contentType = node.content.full.contentType; + if ( + contentType !== contentTypes.markdown && + contentType !== contentTypes.obsidianMarkdown + ) + return `Unsupported Obsidian full content type '${contentType}'`; + + return undefined; +}; + +const replacePageContent = async ({ + markdown, + pageUid, +}: { + markdown: string; + pageUid: string; +}): Promise => { + const children = getShallowTreeByParentUid(pageUid); + await getRoamFromMarkdownApi().data.block.fromMarkdown({ + location: { "parent-uid": pageUid, order: "last" }, + "markdown-string": markdown, + }); + await Promise.all(children.map(({ uid }) => deleteBlock(uid))); +}; + +export const materializeObsidianNode = async ({ + node, + sourceModifiedAt, + sourceNodeRid, +}: { + node: CrossAppNode; +} & ImportedSourceIdentity): Promise => { + const identity = { sourceModifiedAt, sourceNodeRid }; + const validationError = validateInput({ node, ...identity }); + if (validationError) + return failure({ + identity, + message: validationError, + stage: "validate-input", + }); + + const title = node.content.direct.value.trim(); + const markdown = node.content.full.value; + let existingPageUid: string | null; + + try { + existingPageUid = await findImportedNodeUidBySourceRid(sourceNodeRid); + } catch (error) { + return failure({ + error, + identity, + message: `Failed to look up imported Roam node for '${sourceNodeRid}'`, + stage: "find-imported-node", + }); + } + + if (existingPageUid) { + try { + await getRoamFromMarkdownApi().data.page.update({ + page: { title, uid: existingPageUid }, + "merge-pages": false, + }); + } catch (error) { + return failure({ + error, + identity, + message: `Failed to update the Roam page title for '${sourceNodeRid}'`, + pageUid: existingPageUid, + stage: "update-page-title", + }); + } + + try { + await replacePageContent({ markdown, pageUid: existingPageUid }); + } catch (error) { + return failure({ + error, + identity, + message: `Failed to replace Roam content for '${sourceNodeRid}'`, + pageUid: existingPageUid, + stage: "replace-page-content", + }); + } + + try { + await writeImportedSourceIdentity({ + pageUid: existingPageUid, + ...identity, + }); + } catch (error) { + return failure({ + error, + identity, + message: `Content was updated, but source identity could not be refreshed for '${sourceNodeRid}'`, + pageUid: existingPageUid, + stage: "write-source-identity", + }); + } + + return { + ...identity, + success: true, + action: "updated", + pageUid: existingPageUid, + }; + } + + const pageUid = getRoamFromMarkdownApi().util.generateUID(); + try { + await getRoamFromMarkdownApi().data.page.fromMarkdown({ + page: { title, uid: pageUid }, + "markdown-string": markdown, + }); + } catch (error) { + return failure({ + error, + identity, + message: `Failed to create a Roam page for '${sourceNodeRid}'`, + stage: "create-page", + }); + } + + try { + await writeImportedSourceIdentity({ pageUid, ...identity }); + } catch (error) { + let cleanupError: unknown; + try { + await getRoamFromMarkdownApi().data.page.delete({ + page: { uid: pageUid }, + }); + } catch (caughtCleanupError) { + cleanupError = caughtCleanupError; + } + + const cleanupMessage = cleanupError + ? ` Cleanup also failed: ${getErrorMessage(cleanupError)}` + : " The newly created page was removed."; + return failure({ + identity, + message: `Roam content was created, but source identity could not be stored for '${sourceNodeRid}': ${getErrorMessage(error)}.${cleanupMessage}`, + ...(cleanupError ? { pageUid } : {}), + stage: "write-source-identity", + }); + } + + return { + ...identity, + success: true, + action: "created", + pageUid, + }; +};