From dbb154b6ba7f80d9b88cebba931f59463876143c Mon Sep 17 00:00:00 2001 From: sid597 Date: Sun, 12 Jul 2026 23:48:16 +0530 Subject: [PATCH 1/2] [ENG-1858] Materialize Obsidian-origin markdown into Roam --- .../__tests__/importedSourceIdentity.test.ts | 4 +- .../__tests__/materializeObsidianNode.test.ts | 241 ++++++++++++++++ apps/roam/src/utils/importedSourceIdentity.ts | 24 +- .../roam/src/utils/materializeObsidianNode.ts | 269 ++++++++++++++++++ 4 files changed, 527 insertions(+), 11 deletions(-) create mode 100644 apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts create mode 100644 apps/roam/src/utils/materializeObsidianNode.ts diff --git a/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts index 3a0954bd6..a76313f79 100644 --- a/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts +++ b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts @@ -75,7 +75,7 @@ describe("imported source identity metadata", () => { expect(readImportedSourceIdentity(PAGE_UID)).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 }, @@ -83,7 +83,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..60492960e --- /dev/null +++ b/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts @@ -0,0 +1,241 @@ +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: "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), + authorId: "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(); + }); + + it("rejects a node without full content before writing to Roam", async () => { + await expect( + materializeObsidianNode({ + node: { ...node, content: { direct: node.content.direct } }, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }), + ).resolves.toEqual({ + success: false, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + error: { + message: "Source node has no full content to materialize", + 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 17f16eb10..40e5bcd9e 100644 --- a/apps/roam/src/utils/importedSourceIdentity.ts +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -1,7 +1,6 @@ import type { Rid } from "@repo/database/crossAppContracts"; import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; import getBlockProps, { type json } from "./getBlockProps"; -import setBlockProps from "./setBlockProps"; export type ImportedSourceIdentity = { sourceModifiedAt: string; @@ -45,16 +44,23 @@ 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]: { - [SOURCE_MODIFIED_AT_KEY]: sourceModifiedAt, - [SOURCE_NODE_RID_KEY]: sourceNodeRid, + return window.roamAlphaAPI.data.block.update({ + block: { + uid: pageUid, + props: { + ...props, + [DISCOURSE_GRAPH_PROP_NAME]: { + ...discourseGraphProps, + [IMPORTED_FROM_PROP_KEY]: { + [SOURCE_MODIFIED_AT_KEY]: sourceModifiedAt, + [SOURCE_NODE_RID_KEY]: sourceNodeRid, + }, + }, }, }, }); diff --git a/apps/roam/src/utils/materializeObsidianNode.ts b/apps/roam/src/utils/materializeObsidianNode.ts new file mode 100644 index 000000000..54fcf9115 --- /dev/null +++ b/apps/roam/src/utils/materializeObsidianNode.ts @@ -0,0 +1,269 @@ +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): + | { error: string } + | { markdown: string; title: string } => { + if (!sourceNodeRid.trim()) return { error: "Source node RID is required" }; + + const { spaceUri } = ridToSpaceUriAndLocalId(sourceNodeRid); + if (!spaceUri.startsWith("obsidian:")) + return { + error: `Source node RID '${sourceNodeRid}' is not Obsidian-origin`, + }; + + if (Number.isNaN(Date.parse(sourceModifiedAt))) + return { error: `Source modified time '${sourceModifiedAt}' is invalid` }; + + const title = node.content.direct.value.trim(); + if (!title) return { error: "Source node title is required" }; + + const full = node.content.full; + if (!full) return { error: "Source node has no full content to materialize" }; + + if ( + full.contentType !== contentTypes.markdown && + full.contentType !== contentTypes.obsidianMarkdown + ) + return { + error: `Unsupported Obsidian full content type '${full.contentType}'`, + }; + + return { markdown: full.value, title }; +}; + +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 validated = validateInput({ node, ...identity }); + if ("error" in validated) + return failure({ + identity, + message: validated.error, + stage: "validate-input", + }); + + const { markdown, title } = validated; + 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, + }; +}; From 018dbe0523d7396f59e75f59455a876bd6973bed Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 27 Jul 2026 21:52:11 +0530 Subject: [PATCH 2/2] ENG-1858 Address pre-PR review on Obsidian materialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip Obsidian frontmatter before handing markdown to Roam. The `full` content variant is the raw note file, so the YAML block was materializing as literal Roam blocks. Adds `stripFrontmatter` to @repo/content-model. Determine Obsidian origin from the `Platform` enum that discovery already carries, instead of sniffing an `obsidian:` prefix off the RID, and check RID shape with `isRid`. Refuse to create a page whose title is already held by a page that was not imported from this source, so the MVP0 collision behaviour is defined. Replace page content before renaming the title, so a markdown failure leaves the existing page fully untouched. Store `sourceModifiedAt` as canonical UTC, and accept only `text/obsidian+markdown` for `full` — no producer emits `text/markdown`. Restore `setBlockProps` as the single block-props writer by returning its update promise, replacing the copy of its body in `writeImportedSourceIdentity`. Existing call sites keep their current fire-and-forget behaviour via `void`. Narrow the untyped-API cast to the `fromMarkdown` members that are actually missing from roamjs-components, and stop declaring a return value the code ignores. --- apps/roam/src/components/canvas/Clipboard.tsx | 2 +- .../src/components/canvas/canvasSyncMode.ts | 2 +- .../settings/DiscourseNodeConfigPanel.tsx | 2 +- .../components/settings/utils/accessors.ts | 4 +- .../src/components/settings/utils/init.ts | 8 +- .../utils/migrateLegacyToBlockProps.ts | 2 +- .../__tests__/importedSourceIdentity.test.ts | 1 + .../__tests__/materializeObsidianNode.test.ts | 229 +++++++++++++----- apps/roam/src/utils/importedSourceIdentity.ts | 24 +- .../roam/src/utils/materializeObsidianNode.ts | 155 ++++++------ apps/roam/src/utils/migrateRelations.ts | 2 +- apps/roam/src/utils/setBlockProps.ts | 9 +- apps/roam/src/utils/supabaseContext.ts | 2 +- .../content-model/src/__tests__/text.test.ts | 54 +++++ packages/content-model/src/text/index.ts | 18 ++ 15 files changed, 360 insertions(+), 154 deletions(-) create mode 100644 packages/content-model/src/__tests__/text.test.ts diff --git a/apps/roam/src/components/canvas/Clipboard.tsx b/apps/roam/src/components/canvas/Clipboard.tsx index e7ac171ac..b3d11457a 100644 --- a/apps/roam/src/components/canvas/Clipboard.tsx +++ b/apps/roam/src/components/canvas/Clipboard.tsx @@ -202,7 +202,7 @@ export const ClipboardProvider = ({ if (!isInitialized || !clipboardBlockUid) return; try { - setBlockProps(clipboardBlockUid, { + void setBlockProps(clipboardBlockUid, { [CLIPBOARD_PROP_KEY]: pages, [CLIPBOARD_SHOW_NODES_ON_CANVAS_PROP_KEY]: showNodesOnCanvas, }); diff --git a/apps/roam/src/components/canvas/canvasSyncMode.ts b/apps/roam/src/components/canvas/canvasSyncMode.ts index 692b07579..20d8979d9 100644 --- a/apps/roam/src/components/canvas/canvasSyncMode.ts +++ b/apps/roam/src/components/canvas/canvasSyncMode.ts @@ -42,7 +42,7 @@ const setRoamJsQueryBuilderProps = ({ pageUid: string; nextRjsqb: Record; }): void => { - setBlockProps(pageUid, { + void setBlockProps(pageUid, { [QUERY_BUILDER_PROP_KEY]: nextRjsqb, }); }; diff --git a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx index 01a2c289d..f42ef9e07 100644 --- a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx @@ -110,7 +110,7 @@ const DiscourseNodeConfigPanel: React.FC = ({ }, ], }).then((valueUid) => { - setBlockProps( + void setBlockProps( valueUid, DiscourseNodeSchema.parse({ text: label, diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index 1c1fa0ad5..538ec3512 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -657,7 +657,7 @@ const setBlockPropAtPath = ( return currentContext[currentKey]; }, updatedProps); - setBlockProps(blockUid, updatedProps, false); + void setBlockProps(blockUid, updatedProps, false); }; const setBlockPropBasedSettings = ({ @@ -1193,7 +1193,7 @@ export const getAllDiscourseNodes = (): DiscourseNode[] => { ); const retryResult = DiscourseNodeSchema.safeParse(migrated); if (retryResult.success) { - setBlockProps(pageUid, retryResult.data, false); + void setBlockProps(pageUid, retryResult.data, false); nodes.push( toDiscourseNode({ ...retryResult.data, diff --git a/apps/roam/src/components/settings/utils/init.ts b/apps/roam/src/components/settings/utils/init.ts index 9d6116766..5394ff5da 100644 --- a/apps/roam/src/components/settings/utils/init.ts +++ b/apps/roam/src/components/settings/utils/init.ts @@ -163,7 +163,7 @@ const initializeSettingsBlockProps = ( Object.keys(existingProps).length === 0 || !schema.safeParse(existingProps).success ) { - setBlockProps(uid, defaults, false); + void setBlockProps(uid, defaults, false); } // Reconcile placeholder relation keys with real block UIDs. @@ -261,7 +261,11 @@ const reconcileRelationKeys = ( } if (changed) { - setBlockProps(globalBlockUid, { Relations: reconciledRelations }, false); + void setBlockProps( + globalBlockUid, + { Relations: reconciledRelations }, + false, + ); } }; diff --git a/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts b/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts index da29fa11b..88c9c61f2 100644 --- a/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts +++ b/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts @@ -105,7 +105,7 @@ const migrateSection = ({ return true; } - setBlockProps(blockUid, parsedLegacy, false); + void setBlockProps(blockUid, parsedLegacy, false); onWrite?.(); console.log(`${LOG_PREFIX} ${label}: migrated`); return true; diff --git a/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts index a76313f79..602b37cb3 100644 --- a/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts +++ b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts @@ -29,6 +29,7 @@ const setRoamAlphaApi = (): void => { block: { props: Record; uid: string }; }) => { propsByUid.set(block.uid, block.props); + return Promise.resolve(); }, ), }, diff --git a/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts b/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts index 60492960e..9812d6897 100644 --- a/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts +++ b/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts @@ -6,10 +6,15 @@ import { materializeObsidianNode } from "~/utils/materializeObsidianNode"; const mocks = vi.hoisted(() => ({ deleteBlock: vi.fn(), findImportedNodeUidBySourceRid: vi.fn(), + getPageUidByPageTitle: vi.fn(), getShallowTreeByParentUid: vi.fn(), writeImportedSourceIdentity: vi.fn(), })); +vi.mock("roamjs-components/queries/getPageUidByPageTitle", () => ({ + default: mocks.getPageUidByPageTitle, +})); + vi.mock("roamjs-components/queries/getShallowTreeByParentUid", () => ({ default: mocks.getShallowTreeByParentUid, })); @@ -27,16 +32,32 @@ 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 TITLE = "EVD - REM sleep and recall"; + +const SOURCE_MARKDOWN = [ + "---", + "nodeTypeId: evidence-type-id", + "nodeInstanceId: node-1", + "publishedToGroups:", + " - group-a", + "---", + "", + "# REM sleep correlates with recall", + "", + "Updated evidence.", +].join("\n"); + +const MATERIALIZED_MARKDOWN = + "# REM sleep correlates with recall\n\nUpdated evidence."; const node: CrossAppNode = { localId: "node-1", nodeType: "evidence", content: { - direct: { value: "EVD - REM sleep and recall" }, + direct: { value: TITLE }, full: { contentType: contentTypes.obsidianMarkdown, - value: MARKDOWN, + value: SOURCE_MARKDOWN, }, }, createdAt: new Date("2026-06-14T10:30:00.000Z"), @@ -44,6 +65,13 @@ const node: CrossAppNode = { authorId: "author", }; +const input = { + node, + sourceApp: "Obsidian" as const, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, +}; + const pageFromMarkdown = vi.fn(); const blockFromMarkdown = vi.fn(); const deletePage = vi.fn(); @@ -68,25 +96,20 @@ const setRoamAlphaApi = (): void => { beforeEach(() => { vi.clearAllMocks(); mocks.deleteBlock.mockResolvedValue(undefined); + mocks.getPageUidByPageTitle.mockReturnValue(""); mocks.getShallowTreeByParentUid.mockReturnValue([]); - pageFromMarkdown.mockResolvedValue({ uid: NEW_PAGE_UID }); - blockFromMarkdown.mockResolvedValue({ uids: [] }); + pageFromMarkdown.mockResolvedValue(undefined); + blockFromMarkdown.mockResolvedValue(undefined); deletePage.mockResolvedValue(undefined); updatePage.mockResolvedValue(undefined); setRoamAlphaApi(); }); describe("materializeObsidianNode", () => { - it("creates a Roam page from Obsidian markdown and stores source identity", async () => { + it("creates a Roam page from the markdown body and stores source identity", async () => { mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null); - await expect( - materializeObsidianNode({ - node, - sourceModifiedAt: SOURCE_MODIFIED_AT, - sourceNodeRid: SOURCE_NODE_RID, - }), - ).resolves.toEqual({ + await expect(materializeObsidianNode(input)).resolves.toEqual({ success: true, action: "created", pageUid: NEW_PAGE_UID, @@ -96,10 +119,10 @@ describe("materializeObsidianNode", () => { expect(pageFromMarkdown).toHaveBeenCalledWith({ page: { - title: "EVD - REM sleep and recall", + title: TITLE, uid: NEW_PAGE_UID, }, - "markdown-string": MARKDOWN, + "markdown-string": MATERIALIZED_MARKDOWN, }); expect(mocks.writeImportedSourceIdentity).toHaveBeenCalledWith({ pageUid: NEW_PAGE_UID, @@ -108,6 +131,25 @@ describe("materializeObsidianNode", () => { }); }); + it("stores the source modified time as canonical UTC", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null); + + await expect( + materializeObsidianNode({ + ...input, + sourceModifiedAt: "2026-06-14T15:00:00+02:00", + }), + ).resolves.toMatchObject({ + success: true, + sourceModifiedAt: "2026-06-14T13:00:00.000Z", + }); + expect(mocks.writeImportedSourceIdentity).toHaveBeenCalledWith({ + pageUid: NEW_PAGE_UID, + sourceModifiedAt: "2026-06-14T13:00:00.000Z", + 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([ @@ -115,33 +157,27 @@ describe("materializeObsidianNode", () => { { uid: "old-child-2", text: "More old content" }, ]); - await expect( - materializeObsidianNode({ - node, - sourceModifiedAt: SOURCE_MODIFIED_AT, - sourceNodeRid: SOURCE_NODE_RID, - }), - ).resolves.toMatchObject({ + await expect(materializeObsidianNode(input)).resolves.toMatchObject({ success: true, action: "updated", pageUid: EXISTING_PAGE_UID, }); expect(pageFromMarkdown).not.toHaveBeenCalled(); + expect(blockFromMarkdown).toHaveBeenCalledWith({ + location: { "parent-uid": EXISTING_PAGE_UID, order: "last" }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + expect(mocks.deleteBlock).toHaveBeenCalledTimes(2); + expect(mocks.deleteBlock).toHaveBeenCalledWith("old-child-1"); + expect(mocks.deleteBlock).toHaveBeenCalledWith("old-child-2"); expect(updatePage).toHaveBeenCalledWith({ page: { - title: "EVD - REM sleep and recall", + title: TITLE, 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, @@ -149,17 +185,11 @@ describe("materializeObsidianNode", () => { }); }); - it("returns the source identity and failed stage when replacement fails", async () => { + it("leaves the existing page untouched 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({ + await expect(materializeObsidianNode(input)).resolves.toEqual({ success: false, pageUid: EXISTING_PAGE_UID, sourceModifiedAt: SOURCE_MODIFIED_AT, @@ -169,8 +199,9 @@ describe("materializeObsidianNode", () => { stage: "replace-page-content", }, }); - expect(mocks.writeImportedSourceIdentity).not.toHaveBeenCalled(); + expect(updatePage).not.toHaveBeenCalled(); expect(mocks.deleteBlock).not.toHaveBeenCalled(); + expect(mocks.writeImportedSourceIdentity).not.toHaveBeenCalled(); }); it("removes a new page if its source identity cannot be stored", async () => { @@ -179,11 +210,7 @@ describe("materializeObsidianNode", () => { new Error("props update failed"), ); - const result = await materializeObsidianNode({ - node, - sourceModifiedAt: SOURCE_MODIFIED_AT, - sourceNodeRid: SOURCE_NODE_RID, - }); + const result = await materializeObsidianNode(input); expect(result).toMatchObject({ success: false, @@ -197,21 +224,48 @@ describe("materializeObsidianNode", () => { 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"; + it("reports the orphaned page uid when cleanup also fails", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null); + mocks.writeImportedSourceIdentity.mockRejectedValue( + new Error("props update failed"), + ); + deletePage.mockRejectedValue(new Error("delete refused")); + + await expect(materializeObsidianNode(input)).resolves.toMatchObject({ + success: false, + pageUid: NEW_PAGE_UID, + error: { + stage: "write-source-identity", + }, + }); + }); + + it("refuses to clobber a Roam page that was not imported from this source", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null); + mocks.getPageUidByPageTitle.mockReturnValue("local-page-uid"); + await expect(materializeObsidianNode(input)).resolves.toEqual({ + success: false, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + error: { + message: `A Roam page titled '${TITLE}' already exists and was not imported from '${SOURCE_NODE_RID}'`, + stage: "title-collision", + }, + }); + expect(pageFromMarkdown).not.toHaveBeenCalled(); + expect(mocks.writeImportedSourceIdentity).not.toHaveBeenCalled(); + }); + + it("rejects a non-Obsidian source app before writing to Roam", async () => { await expect( - materializeObsidianNode({ - node, - sourceModifiedAt: SOURCE_MODIFIED_AT, - sourceNodeRid, - }), + materializeObsidianNode({ ...input, sourceApp: "Roam" }), ).resolves.toEqual({ success: false, sourceModifiedAt: SOURCE_MODIFIED_AT, - sourceNodeRid, + sourceNodeRid: SOURCE_NODE_RID, error: { - message: `Source node RID '${sourceNodeRid}' is not Obsidian-origin`, + message: "Source app 'Roam' is not Obsidian", stage: "validate-input", }, }); @@ -219,12 +273,26 @@ describe("materializeObsidianNode", () => { expect(pageFromMarkdown).not.toHaveBeenCalled(); }); + it("rejects a source identifier that is not a RID", async () => { + await expect( + materializeObsidianNode({ ...input, sourceNodeRid: "node-1" }), + ).resolves.toEqual({ + success: false, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: "node-1", + error: { + message: "Source node RID 'node-1' is not a RID", + stage: "validate-input", + }, + }); + expect(mocks.findImportedNodeUidBySourceRid).not.toHaveBeenCalled(); + }); + it("rejects a node without full content before writing to Roam", async () => { await expect( materializeObsidianNode({ + ...input, node: { ...node, content: { direct: node.content.direct } }, - sourceModifiedAt: SOURCE_MODIFIED_AT, - sourceNodeRid: SOURCE_NODE_RID, }), ).resolves.toEqual({ success: false, @@ -238,4 +306,55 @@ describe("materializeObsidianNode", () => { expect(mocks.findImportedNodeUidBySourceRid).not.toHaveBeenCalled(); expect(pageFromMarkdown).not.toHaveBeenCalled(); }); + + it("rejects a node whose markdown is only frontmatter", async () => { + await expect( + materializeObsidianNode({ + ...input, + node: { + ...node, + content: { + ...node.content, + full: { + contentType: contentTypes.obsidianMarkdown, + value: "---\nnodeInstanceId: node-1\n---\n", + }, + }, + }, + }), + ).resolves.toEqual({ + success: false, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + error: { + message: "Source node has no markdown body outside its frontmatter", + stage: "validate-input", + }, + }); + expect(pageFromMarkdown).not.toHaveBeenCalled(); + }); + + it("rejects a full content type Roam cannot materialize", async () => { + await expect( + materializeObsidianNode({ + ...input, + node: { + ...node, + content: { + ...node.content, + full: { + contentType: contentTypes.roamJson, + value: "{}", + }, + }, + }, + }), + ).resolves.toMatchObject({ + success: false, + error: { + message: `Unsupported Obsidian full content type '${contentTypes.roamJson}'`, + stage: "validate-input", + }, + }); + }); }); diff --git a/apps/roam/src/utils/importedSourceIdentity.ts b/apps/roam/src/utils/importedSourceIdentity.ts index 40e5bcd9e..3f810adfd 100644 --- a/apps/roam/src/utils/importedSourceIdentity.ts +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -1,6 +1,7 @@ import type { Rid } from "@repo/database/crossAppContracts"; import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; import getBlockProps, { type json } from "./getBlockProps"; +import setBlockProps from "./setBlockProps"; export type ImportedSourceIdentity = { sourceModifiedAt: string; @@ -36,7 +37,7 @@ export const readImportedSourceIdentity = ( ): ImportedSourceIdentity | undefined => parseImportedSourceIdentity(getBlockProps(pageUid)); -export const writeImportedSourceIdentity = ({ +export const writeImportedSourceIdentity = async ({ pageUid, sourceModifiedAt, sourceNodeRid, @@ -45,22 +46,15 @@ export const writeImportedSourceIdentity = ({ sourceModifiedAt: string; sourceNodeRid: string; }): Promise => { - const props = getBlockProps(pageUid); - const existing = props[DISCOURSE_GRAPH_PROP_NAME]; + const existing = getBlockProps(pageUid)[DISCOURSE_GRAPH_PROP_NAME]; const discourseGraphProps = isJsonObject(existing) ? existing : {}; - return window.roamAlphaAPI.data.block.update({ - block: { - uid: pageUid, - props: { - ...props, - [DISCOURSE_GRAPH_PROP_NAME]: { - ...discourseGraphProps, - [IMPORTED_FROM_PROP_KEY]: { - [SOURCE_MODIFIED_AT_KEY]: sourceModifiedAt, - [SOURCE_NODE_RID_KEY]: sourceNodeRid, - }, - }, + await setBlockProps(pageUid, { + [DISCOURSE_GRAPH_PROP_NAME]: { + ...discourseGraphProps, + [IMPORTED_FROM_PROP_KEY]: { + [SOURCE_MODIFIED_AT_KEY]: sourceModifiedAt, + [SOURCE_NODE_RID_KEY]: sourceNodeRid, }, }, }); diff --git a/apps/roam/src/utils/materializeObsidianNode.ts b/apps/roam/src/utils/materializeObsidianNode.ts index 54fcf9115..a23ddf606 100644 --- a/apps/roam/src/utils/materializeObsidianNode.ts +++ b/apps/roam/src/utils/materializeObsidianNode.ts @@ -1,6 +1,8 @@ -import { contentTypes } from "@repo/content-model"; +import { contentTypes, stripFrontmatter } from "@repo/content-model"; import type { CrossAppNode } from "@repo/database/crossAppContracts"; -import { ridToSpaceUriAndLocalId } from "@repo/database/lib/rid"; +import type { Enums } from "@repo/database/dbTypes"; +import { isRid } from "@repo/database/lib/rid"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeByParentUid"; import deleteBlock from "roamjs-components/writes/deleteBlock"; import { @@ -12,9 +14,10 @@ import { type MaterializationStage = | "validate-input" | "find-imported-node" + | "title-collision" | "create-page" - | "update-page-title" | "replace-page-content" + | "update-page-title" | "write-source-identity"; type MaterializationFailure = ImportedSourceIdentity & { @@ -36,33 +39,32 @@ 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; - }; +type MaterializeObsidianNodeInput = ImportedSourceIdentity & { + node: CrossAppNode; + sourceApp: Enums<"Platform">; +}; + +type RoamMarkdownApi = { + block: { + fromMarkdown: (args: { + location: { "parent-uid": string; order: "last" }; + "markdown-string": string; + }) => Promise; }; - util: { - generateUID: () => string; + page: { + fromMarkdown: (args: { + page: { title: string; uid: string }; + "markdown-string": string; + }) => Promise; + update: (args: { + page: { title: string; uid: string }; + "merge-pages": false; + }) => Promise; }; }; -const getRoamFromMarkdownApi = (): RoamFromMarkdownApi => - window.roamAlphaAPI as unknown as RoamFromMarkdownApi; +const getRoamMarkdownApi = (): RoamMarkdownApi => + window.roamAlphaAPI.data as unknown as RoamMarkdownApi; const getErrorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); @@ -91,22 +93,20 @@ const failure = ({ const validateInput = ({ node, + sourceApp, sourceModifiedAt, sourceNodeRid, -}: { - node: CrossAppNode; -} & ImportedSourceIdentity): +}: MaterializeObsidianNodeInput): | { error: string } - | { markdown: string; title: string } => { - if (!sourceNodeRid.trim()) return { error: "Source node RID is required" }; + | { markdown: string; sourceModifiedAt: string; title: string } => { + if (!isRid(sourceNodeRid)) + return { error: `Source node RID '${sourceNodeRid}' is not a RID` }; - const { spaceUri } = ridToSpaceUriAndLocalId(sourceNodeRid); - if (!spaceUri.startsWith("obsidian:")) - return { - error: `Source node RID '${sourceNodeRid}' is not Obsidian-origin`, - }; + if (sourceApp !== "Obsidian") + return { error: `Source app '${sourceApp}' is not Obsidian` }; - if (Number.isNaN(Date.parse(sourceModifiedAt))) + const modifiedAt = new Date(sourceModifiedAt); + if (Number.isNaN(modifiedAt.getTime())) return { error: `Source modified time '${sourceModifiedAt}' is invalid` }; const title = node.content.direct.value.trim(); @@ -115,15 +115,18 @@ const validateInput = ({ const full = node.content.full; if (!full) return { error: "Source node has no full content to materialize" }; - if ( - full.contentType !== contentTypes.markdown && - full.contentType !== contentTypes.obsidianMarkdown - ) + if (full.contentType !== contentTypes.obsidianMarkdown) return { error: `Unsupported Obsidian full content type '${full.contentType}'`, }; - return { markdown: full.value, title }; + const markdown = stripFrontmatter(full.value).trim(); + if (!markdown) + return { + error: "Source node has no markdown body outside its frontmatter", + }; + + return { markdown, sourceModifiedAt: modifiedAt.toISOString(), title }; }; const replacePageContent = async ({ @@ -133,33 +136,40 @@ const replacePageContent = async ({ markdown: string; pageUid: string; }): Promise => { - const children = getShallowTreeByParentUid(pageUid); - await getRoamFromMarkdownApi().data.block.fromMarkdown({ + const previousChildren = getShallowTreeByParentUid(pageUid); + await getRoamMarkdownApi().block.fromMarkdown({ location: { "parent-uid": pageUid, order: "last" }, "markdown-string": markdown, }); - await Promise.all(children.map(({ uid }) => deleteBlock(uid))); + await Promise.all(previousChildren.map(({ uid }) => deleteBlock(uid))); }; export const materializeObsidianNode = async ({ node, + sourceApp, sourceModifiedAt, sourceNodeRid, -}: { - node: CrossAppNode; -} & ImportedSourceIdentity): Promise => { - const identity = { sourceModifiedAt, sourceNodeRid }; - const validated = validateInput({ node, ...identity }); +}: MaterializeObsidianNodeInput): Promise => { + const validated = validateInput({ + node, + sourceApp, + sourceModifiedAt, + sourceNodeRid, + }); if ("error" in validated) return failure({ - identity, + identity: { sourceModifiedAt, sourceNodeRid }, message: validated.error, stage: "validate-input", }); const { markdown, title } = validated; - let existingPageUid: string | null; + const identity: ImportedSourceIdentity = { + sourceModifiedAt: validated.sourceModifiedAt, + sourceNodeRid, + }; + let existingPageUid: string | null; try { existingPageUid = await findImportedNodeUidBySourceRid(sourceNodeRid); } catch (error) { @@ -173,29 +183,29 @@ export const materializeObsidianNode = async ({ if (existingPageUid) { try { - await getRoamFromMarkdownApi().data.page.update({ - page: { title, uid: existingPageUid }, - "merge-pages": false, - }); + await replacePageContent({ markdown, pageUid: existingPageUid }); } catch (error) { return failure({ error, identity, - message: `Failed to update the Roam page title for '${sourceNodeRid}'`, + message: `Failed to replace Roam content for '${sourceNodeRid}'`, pageUid: existingPageUid, - stage: "update-page-title", + stage: "replace-page-content", }); } try { - await replacePageContent({ markdown, pageUid: existingPageUid }); + await getRoamMarkdownApi().page.update({ + page: { title, uid: existingPageUid }, + "merge-pages": false, + }); } catch (error) { return failure({ error, identity, - message: `Failed to replace Roam content for '${sourceNodeRid}'`, + message: `Content was replaced, but the Roam page title could not be updated for '${sourceNodeRid}'`, pageUid: existingPageUid, - stage: "replace-page-content", + stage: "update-page-title", }); } @@ -222,9 +232,16 @@ export const materializeObsidianNode = async ({ }; } - const pageUid = getRoamFromMarkdownApi().util.generateUID(); + if (getPageUidByPageTitle(title)) + return failure({ + identity, + message: `A Roam page titled '${title}' already exists and was not imported from '${sourceNodeRid}'`, + stage: "title-collision", + }); + + const pageUid = window.roamAlphaAPI.util.generateUID(); try { - await getRoamFromMarkdownApi().data.page.fromMarkdown({ + await getRoamMarkdownApi().page.fromMarkdown({ page: { title, uid: pageUid }, "markdown-string": markdown, }); @@ -242,19 +259,17 @@ export const materializeObsidianNode = async ({ } catch (error) { let cleanupError: unknown; try { - await getRoamFromMarkdownApi().data.page.delete({ - page: { uid: pageUid }, - }); + await window.roamAlphaAPI.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({ + error, identity, - message: `Roam content was created, but source identity could not be stored for '${sourceNodeRid}': ${getErrorMessage(error)}.${cleanupMessage}`, + message: cleanupError + ? `Roam content was created for '${sourceNodeRid}' and could not be removed after source identity failed to store (cleanup error: ${getErrorMessage(cleanupError)})` + : `Roam content was created and then removed because source identity could not be stored for '${sourceNodeRid}'`, ...(cleanupError ? { pageUid } : {}), stage: "write-source-identity", }); diff --git a/apps/roam/src/utils/migrateRelations.ts b/apps/roam/src/utils/migrateRelations.ts index 14f103671..30cb90655 100644 --- a/apps/roam/src/utils/migrateRelations.ts +++ b/apps/roam/src/utils/migrateRelations.ts @@ -55,7 +55,7 @@ const migrateRelations = async (): Promise => { ); migrationData[uid] = new Date().valueOf(); dgData[MIGRATION_PROP_NAME] = migrationData; - setBlockProps(rel.source, { [DISCOURSE_GRAPH_PROP_NAME]: dgData }); + void setBlockProps(rel.source, { [DISCOURSE_GRAPH_PROP_NAME]: dgData }); numProcessed++; } } catch (error) { diff --git a/apps/roam/src/utils/setBlockProps.ts b/apps/roam/src/utils/setBlockProps.ts index 1f8d9d52d..93dacd6be 100644 --- a/apps/roam/src/utils/setBlockProps.ts +++ b/apps/roam/src/utils/setBlockProps.ts @@ -23,7 +23,7 @@ const setBlockProps = ( uid: string, newProps: Record, denormalize: boolean = false, -) => { +): Promise => { const rawBaseProps = getRawBlockProps(uid); const baseProps = denormalize ? rawBaseProps : normalizeProps(rawBaseProps); if (typeof baseProps === "object" && !Array.isArray(baseProps)) { @@ -33,10 +33,11 @@ const setBlockProps = ( ? (deNormalizeProps(newProps) as Record) : newProps), } as Record; - window.roamAlphaAPI.data.block.update({ block: { uid, props } }); - return props; + return window.roamAlphaAPI.data.block + .update({ block: { uid, props } }) + .then(() => props); } - return baseProps; + return Promise.resolve(baseProps); }; export const testSetBlockProps = ( diff --git a/apps/roam/src/utils/supabaseContext.ts b/apps/roam/src/utils/supabaseContext.ts index 5c9d8fef0..4146d914c 100644 --- a/apps/roam/src/utils/supabaseContext.ts +++ b/apps/roam/src/utils/supabaseContext.ts @@ -38,7 +38,7 @@ const getOrCreateSpacePassword = () => { if (existing && typeof existing === "string") return existing; // use a uuid as password, at least cryptographically safe const password = crypto.randomUUID(); - setBlockProps(settingsConfigPageUid, { + void setBlockProps(settingsConfigPageUid, { "space-user-password": password, }); return password; diff --git a/packages/content-model/src/__tests__/text.test.ts b/packages/content-model/src/__tests__/text.test.ts new file mode 100644 index 000000000..c1b0bdc87 --- /dev/null +++ b/packages/content-model/src/__tests__/text.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { stripFrontmatter } from "@repo/content-model"; + +describe("stripFrontmatter", () => { + it("removes an Obsidian frontmatter block and the blank lines after it", () => { + expect( + stripFrontmatter( + [ + "---", + "nodeTypeId: evidence-type-id", + "nodeInstanceId: node-1", + "publishedToGroups:", + " - group-a", + "---", + "", + "# REM sleep correlates with recall", + "", + "Updated evidence.", + ].join("\n"), + ), + ).toBe("# REM sleep correlates with recall\n\nUpdated evidence."); + }); + + it("removes an empty frontmatter block", () => { + expect(stripFrontmatter("---\n---\nBody.")).toBe("Body."); + }); + + it("normalizes carriage returns before matching the delimiter", () => { + expect( + stripFrontmatter("---\r\nnodeInstanceId: node-1\r\n---\r\nBody."), + ).toBe("Body."); + }); + + it("returns markdown that has no frontmatter unchanged", () => { + expect(stripFrontmatter("# Heading\n\nBody.")).toBe("# Heading\n\nBody."); + }); + + it("keeps a horizontal rule that is not a frontmatter delimiter", () => { + expect(stripFrontmatter("Body.\n\n---\n\nMore body.")).toBe( + "Body.\n\n---\n\nMore body.", + ); + }); + + it("returns the document unchanged when the frontmatter block is unterminated", () => { + expect(stripFrontmatter("---\nnodeInstanceId: node-1\nBody.")).toBe( + "---\nnodeInstanceId: node-1\nBody.", + ); + }); + + it("returns an empty string for a frontmatter-only document", () => { + expect(stripFrontmatter("---\nnodeInstanceId: node-1\n---\n")).toBe(""); + }); +}); diff --git a/packages/content-model/src/text/index.ts b/packages/content-model/src/text/index.ts index eed1bf8c7..0671808ed 100644 --- a/packages/content-model/src/text/index.ts +++ b/packages/content-model/src/text/index.ts @@ -1,2 +1,20 @@ export const normalizeLineEndings = (text: string): string => text.replace(/\r\n?/g, "\n"); + +const FRONTMATTER_DELIMITER = "---"; + +export const stripFrontmatter = (markdown: string): string => { + const normalized = normalizeLineEndings(markdown); + if (!normalized.startsWith(`${FRONTMATTER_DELIMITER}\n`)) return normalized; + + const lines = normalized.split("\n"); + const closingIndex = lines.findIndex( + (line, index) => index > 0 && line.trimEnd() === FRONTMATTER_DELIMITER, + ); + if (closingIndex === -1) return normalized; + + return lines + .slice(closingIndex + 1) + .join("\n") + .replace(/^\n+/, ""); +};