diff --git a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx index 4a6b94fbb..fb87c6859 100644 --- a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx +++ b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx @@ -30,9 +30,6 @@ const IMPORT_ERROR_OPERATION = "import-shared-nodes"; const formatModifiedAt = (modifiedAt: string): string => new Date(modifiedAt).toLocaleString(); -const isImportableSharedNode = (node: SharedNode): boolean => - node.platform === "Obsidian"; - const SharedNodeRow = ({ node, alreadyImported, @@ -52,7 +49,7 @@ const SharedNodeRow = ({ aria-label={`Select ${node.title}`} checked={selected} className="m-0" - disabled={selectionDisabled || !isImportableSharedNode(node)} + disabled={selectionDisabled} onChange={onToggleSelected} /> @@ -209,15 +206,10 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => { ); }, [nodes, searchTerm]); - const importableVisibleRids = visibleNodes - .filter(isImportableSharedNode) - .map((node) => node.rid); + const visibleRids = visibleNodes.map((node) => node.rid); const allVisibleSelected = - importableVisibleRids.length > 0 && - importableVisibleRids.every((rid) => selectedRids.has(rid)); - const someVisibleSelected = importableVisibleRids.some((rid) => - selectedRids.has(rid), - ); + visibleRids.length > 0 && visibleRids.every((rid) => selectedRids.has(rid)); + const someVisibleSelected = visibleRids.some((rid) => selectedRids.has(rid)); const toggleNodeSelected = (rid: string): void => { setSelectedRids((previous) => { @@ -231,9 +223,8 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => { const toggleAllVisibleSelected = (): void => { setSelectedRids((previous) => { const next = new Set(previous); - if (allVisibleSelected) - importableVisibleRids.forEach((rid) => next.delete(rid)); - else importableVisibleRids.forEach((rid) => next.add(rid)); + if (allVisibleSelected) visibleRids.forEach((rid) => next.delete(rid)); + else visibleRids.forEach((rid) => next.add(rid)); return next; }); }; @@ -360,10 +351,10 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => { diff --git a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts index be20076ee..aa52bf039 100644 --- a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts +++ b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts @@ -63,6 +63,16 @@ const sharedNode: SharedNode = { directMetadata: null, }; +const roamSharedNode: SharedNode = { + ...sharedNode, + rid: "https://roamresearch.com/#/app/source-graph/node-2", + sourceLocalId: "node-2", + spaceId: 21, + spaceName: "Source graph", + spaceUri: "https://roamresearch.com/#/app/source-graph", + platform: "Roam", +}; + const FULL_MARKDOWN = [ "---", "nodeTypeId: evidence", @@ -335,20 +345,82 @@ describe("materializeSharedNode", () => { expect(mockedWriteImportedSourceIdentity).not.toHaveBeenCalled(); }); - it("rejects a non-Obsidian source before fetching content", async () => { - const { client, from } = clientWithFullContent({ text: FULL_MARKDOWN }); + it("imports a Roam-origin node and strips the duplicated title heading", async () => { + const { client } = clientWithFullContent({ + text: `# ${roamSharedNode.title}\n\n- REM sleep improves recall\n`, + contentType: "text/roam+markdown", + }); + + await expect( + materializeSharedNode({ client, sharedNode: roamSharedNode }), + ).resolves.toEqual({ + success: true, + action: "created", + pageUid: GENERATED_PAGE_UID, + sourceModifiedAt: roamSharedNode.lastModified, + sourceNodeRid: roamSharedNode.rid, + }); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: roamSharedNode.title, uid: GENERATED_PAGE_UID }, + "markdown-string": "- REM sleep improves recall", + }); + }); + + it("keeps a first line that does not match the shared title exactly", async () => { + const { client } = clientWithFullContent({ + text: "# Some other heading\n\n- body", + contentType: "text/roam+markdown", + }); const result = await materializeSharedNode({ client, - sharedNode: { ...sharedNode, platform: "Roam" }, + sharedNode: roamSharedNode, + }); + + expect(result.success).toBe(true); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: roamSharedNode.title, uid: GENERATED_PAGE_UID }, + "markdown-string": "# Some other heading\n\n- body", + }); + }); + + it("creates a title-only page when Roam full content is only the heading", async () => { + const { client } = clientWithFullContent({ + text: `# ${roamSharedNode.title}\n`, + contentType: "text/roam+markdown", + }); + + const result = await materializeSharedNode({ + client, + sharedNode: roamSharedNode, + }); + + expect(result.success).toBe(true); + expect(pageCreate).toHaveBeenCalledWith({ + page: { title: roamSharedNode.title, uid: GENERATED_PAGE_UID }, + }); + expect(pageFromMarkdown).not.toHaveBeenCalled(); + }); + + it("rejects Obsidian markdown on a Roam-origin node", async () => { + const { client } = clientWithFullContent({ + text: `# ${roamSharedNode.title}\n\n- body`, + contentType: "text/obsidian+markdown", + }); + + const result = await materializeSharedNode({ + client, + sharedNode: roamSharedNode, }); expect(result).toMatchObject({ success: false, - error: { stage: "validate-input" }, + error: { stage: "fetch-content" }, }); - expect(result.success === false && result.error.message).toContain("Roam"); - expect(from).not.toHaveBeenCalled(); + expect(result.success === false && result.error.message).toContain( + "text/roam+markdown", + ); + expect(pageFromMarkdown).not.toHaveBeenCalled(); }); it("rejects a source identifier that is not a RID", async () => { diff --git a/apps/roam/src/utils/materializeSharedNode.ts b/apps/roam/src/utils/materializeSharedNode.ts index 9c9300e39..6e36569e6 100644 --- a/apps/roam/src/utils/materializeSharedNode.ts +++ b/apps/roam/src/utils/materializeSharedNode.ts @@ -1,6 +1,7 @@ import { contentTypes, stripFrontmatter, + stripTitleHeading, trimBlankLines, } from "@repo/content-model"; import type { DGSupabaseClient } from "@repo/database/lib/client"; @@ -110,11 +111,6 @@ const failure = ({ const validateSharedNode = ( sharedNode: SharedNode, ): { error: string } | { sourceModifiedAt: string; title: string } => { - if (sharedNode.platform !== "Obsidian") - return { - error: `Materialization only supports Obsidian-origin nodes, and this node comes from ${sharedNode.platform}`, - }; - if (!isRid(sharedNode.rid)) return { error: `Source node RID "${sharedNode.rid}" is not a RID` }; @@ -147,11 +143,19 @@ const fetchFullMarkdown = async ({ .maybeSingle(); if (error) return { error: error.message }; if (!data?.text) return { markdown: "" }; - if (data.content_type !== contentTypes.obsidianMarkdown) + const expectedContentType = + sharedNode.platform === "Roam" + ? contentTypes.roamMarkdown + : contentTypes.obsidianMarkdown; + if (data.content_type !== expectedContentType) return { - error: `Unsupported full content type "${data.content_type}" — expected "${contentTypes.obsidianMarkdown}"`, + error: `Unsupported full content type "${data.content_type}" — expected "${expectedContentType}"`, }; - const markdown = trimBlankLines(stripFrontmatter(data.text)); + const withoutPreamble = + sharedNode.platform === "Roam" + ? stripTitleHeading({ markdown: data.text, title: sharedNode.title }) + : stripFrontmatter(data.text); + const markdown = trimBlankLines(withoutPreamble); return { markdown: markdown.trim() ? markdown : "" }; }; diff --git a/packages/content-model/src/__tests__/text.test.ts b/packages/content-model/src/__tests__/text.test.ts index ed05450ea..6d446abe3 100644 --- a/packages/content-model/src/__tests__/text.test.ts +++ b/packages/content-model/src/__tests__/text.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { normalizeLineEndings, stripFrontmatter, + stripTitleHeading, trimBlankLines, } from "../text/index.js"; @@ -23,6 +24,36 @@ describe("trimBlankLines", () => { }); }); +describe("stripTitleHeading", () => { + it("strips a title heading first line and following blank lines", () => { + expect( + stripTitleHeading({ markdown: "# Title\n\nbody", title: "Title" }), + ).toBe("body"); + }); + + it("leaves markdown whose first line differs from the title", () => { + expect( + stripTitleHeading({ markdown: "# Other\n\nbody", title: "Title" }), + ).toBe("# Other\n\nbody"); + }); + + it("requires an exact match, not a prefix", () => { + expect( + stripTitleHeading({ markdown: "# Title extra\nbody", title: "Title" }), + ).toBe("# Title extra\nbody"); + }); + + it("returns an empty string for heading-only markdown", () => { + expect(stripTitleHeading({ markdown: "# Title", title: "Title" })).toBe(""); + }); + + it("handles CRLF line endings", () => { + expect( + stripTitleHeading({ markdown: "# Title\r\nbody", title: "Title" }), + ).toBe("body"); + }); +}); + describe("stripFrontmatter", () => { it("removes a leading YAML frontmatter block and following blank lines", () => { expect( diff --git a/packages/content-model/src/text/index.ts b/packages/content-model/src/text/index.ts index 50f1d7281..08e96d329 100644 --- a/packages/content-model/src/text/index.ts +++ b/packages/content-model/src/text/index.ts @@ -4,6 +4,22 @@ export const normalizeLineEndings = (text: string): string => export const trimBlankLines = (text: string): string => text.replace(/^(?:[ \t]*\n)+/, "").replace(/(?:\n[ \t]*)+$/, ""); +export const stripTitleHeading = ({ + markdown, + title, +}: { + markdown: string; + title: string; +}): string => { + const normalized = normalizeLineEndings(markdown); + const newlineIndex = normalized.indexOf("\n"); + const firstLine = + newlineIndex === -1 ? normalized : normalized.slice(0, newlineIndex); + if (firstLine !== `# ${title}`) return normalized; + if (newlineIndex === -1) return ""; + return normalized.slice(newlineIndex + 1).replace(/^\n+/, ""); +}; + const FRONTMATTER_DELIMITER = "---"; export const stripFrontmatter = (markdown: string): string => {