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 3a0954bd6..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(); }, ), }, @@ -75,7 +76,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 +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..9812d6897 --- /dev/null +++ b/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts @@ -0,0 +1,360 @@ +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(), + 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, +})); + +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 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: TITLE }, + full: { + contentType: contentTypes.obsidianMarkdown, + value: SOURCE_MARKDOWN, + }, + }, + createdAt: new Date("2026-06-14T10:30:00.000Z"), + modifiedAt: new Date(SOURCE_MODIFIED_AT), + 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(); +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.getPageUidByPageTitle.mockReturnValue(""); + mocks.getShallowTreeByParentUid.mockReturnValue([]); + pageFromMarkdown.mockResolvedValue(undefined); + blockFromMarkdown.mockResolvedValue(undefined); + deletePage.mockResolvedValue(undefined); + updatePage.mockResolvedValue(undefined); + setRoamAlphaApi(); +}); + +describe("materializeObsidianNode", () => { + it("creates a Roam page from the markdown body and stores source identity", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null); + + await expect(materializeObsidianNode(input)).resolves.toEqual({ + success: true, + action: "created", + pageUid: NEW_PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { + title: TITLE, + uid: NEW_PAGE_UID, + }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + expect(mocks.writeImportedSourceIdentity).toHaveBeenCalledWith({ + pageUid: NEW_PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + }); + + 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([ + { uid: "old-child-1", text: "Old content" }, + { uid: "old-child-2", text: "More old content" }, + ]); + + 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: TITLE, + uid: EXISTING_PAGE_UID, + }, + "merge-pages": false, + }); + expect(mocks.writeImportedSourceIdentity).toHaveBeenCalledWith({ + pageUid: EXISTING_PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + }); + + 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(input)).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(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 () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null); + mocks.writeImportedSourceIdentity.mockRejectedValue( + new Error("props update failed"), + ); + + const result = await materializeObsidianNode(input); + + 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("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({ ...input, sourceApp: "Roam" }), + ).resolves.toEqual({ + success: false, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + error: { + message: "Source app 'Roam' is not Obsidian", + stage: "validate-input", + }, + }); + expect(mocks.findImportedNodeUidBySourceRid).not.toHaveBeenCalled(); + 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 } }, + }), + ).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(); + }); + + 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 17f16eb10..3f810adfd 100644 --- a/apps/roam/src/utils/importedSourceIdentity.ts +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -37,7 +37,7 @@ export const readImportedSourceIdentity = ( ): ImportedSourceIdentity | undefined => parseImportedSourceIdentity(getBlockProps(pageUid)); -export const writeImportedSourceIdentity = ({ +export const writeImportedSourceIdentity = async ({ pageUid, sourceModifiedAt, sourceNodeRid, @@ -45,11 +45,11 @@ export const writeImportedSourceIdentity = ({ pageUid: string; sourceModifiedAt: string; sourceNodeRid: string; -}): void => { +}): Promise => { const existing = getBlockProps(pageUid)[DISCOURSE_GRAPH_PROP_NAME]; const discourseGraphProps = isJsonObject(existing) ? existing : {}; - setBlockProps(pageUid, { + await setBlockProps(pageUid, { [DISCOURSE_GRAPH_PROP_NAME]: { ...discourseGraphProps, [IMPORTED_FROM_PROP_KEY]: { diff --git a/apps/roam/src/utils/materializeObsidianNode.ts b/apps/roam/src/utils/materializeObsidianNode.ts new file mode 100644 index 000000000..a23ddf606 --- /dev/null +++ b/apps/roam/src/utils/materializeObsidianNode.ts @@ -0,0 +1,284 @@ +import { contentTypes, stripFrontmatter } from "@repo/content-model"; +import type { CrossAppNode } from "@repo/database/crossAppContracts"; +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 { + findImportedNodeUidBySourceRid, + type ImportedSourceIdentity, + writeImportedSourceIdentity, +} from "./importedSourceIdentity"; + +type MaterializationStage = + | "validate-input" + | "find-imported-node" + | "title-collision" + | "create-page" + | "replace-page-content" + | "update-page-title" + | "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 MaterializeObsidianNodeInput = ImportedSourceIdentity & { + node: CrossAppNode; + sourceApp: Enums<"Platform">; +}; + +type RoamMarkdownApi = { + block: { + fromMarkdown: (args: { + location: { "parent-uid": string; order: "last" }; + "markdown-string": string; + }) => Promise; + }; + page: { + fromMarkdown: (args: { + page: { title: string; uid: string }; + "markdown-string": string; + }) => Promise; + update: (args: { + page: { title: string; uid: string }; + "merge-pages": false; + }) => Promise; + }; +}; + +const getRoamMarkdownApi = (): RoamMarkdownApi => + window.roamAlphaAPI.data as unknown as RoamMarkdownApi; + +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, + sourceApp, + sourceModifiedAt, + sourceNodeRid, +}: MaterializeObsidianNodeInput): + | { error: string } + | { markdown: string; sourceModifiedAt: string; title: string } => { + if (!isRid(sourceNodeRid)) + return { error: `Source node RID '${sourceNodeRid}' is not a RID` }; + + if (sourceApp !== "Obsidian") + return { error: `Source app '${sourceApp}' is not Obsidian` }; + + 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(); + 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.obsidianMarkdown) + return { + error: `Unsupported Obsidian full content type '${full.contentType}'`, + }; + + 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 ({ + markdown, + pageUid, +}: { + markdown: string; + pageUid: string; +}): Promise => { + const previousChildren = getShallowTreeByParentUid(pageUid); + await getRoamMarkdownApi().block.fromMarkdown({ + location: { "parent-uid": pageUid, order: "last" }, + "markdown-string": markdown, + }); + await Promise.all(previousChildren.map(({ uid }) => deleteBlock(uid))); +}; + +export const materializeObsidianNode = async ({ + node, + sourceApp, + sourceModifiedAt, + sourceNodeRid, +}: MaterializeObsidianNodeInput): Promise => { + const validated = validateInput({ + node, + sourceApp, + sourceModifiedAt, + sourceNodeRid, + }); + if ("error" in validated) + return failure({ + identity: { sourceModifiedAt, sourceNodeRid }, + message: validated.error, + stage: "validate-input", + }); + + const { markdown, title } = validated; + const identity: ImportedSourceIdentity = { + sourceModifiedAt: validated.sourceModifiedAt, + sourceNodeRid, + }; + + 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 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 getRoamMarkdownApi().page.update({ + page: { title, uid: existingPageUid }, + "merge-pages": false, + }); + } catch (error) { + return failure({ + error, + identity, + message: `Content was replaced, but the Roam page title could not be updated for '${sourceNodeRid}'`, + pageUid: existingPageUid, + stage: "update-page-title", + }); + } + + 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, + }; + } + + 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 getRoamMarkdownApi().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 window.roamAlphaAPI.data.page.delete({ page: { uid: pageUid } }); + } catch (caughtCleanupError) { + cleanupError = caughtCleanupError; + } + + return failure({ + error, + identity, + 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", + }); + } + + return { + ...identity, + success: true, + action: "created", + pageUid, + }; +}; 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+/, ""); +};