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__/materializeSharedNode.test.ts b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts new file mode 100644 index 000000000..eb562462a --- /dev/null +++ b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts @@ -0,0 +1,429 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeByParentUid"; +import deleteBlock from "roamjs-components/writes/deleteBlock"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import type { SharedNode } from "@repo/database/lib/sharedNodes"; +import { + findImportedNodeUidBySourceRid, + writeImportedSourceIdentity, +} from "~/utils/importedSourceIdentity"; +import { materializeSharedNode } from "~/utils/materializeSharedNode"; + +vi.mock("roamjs-components/queries/getPageTitleByPageUid", () => ({ + default: vi.fn(), +})); +vi.mock("roamjs-components/queries/getPageUidByPageTitle", () => ({ + default: vi.fn(), +})); +vi.mock("roamjs-components/queries/getShallowTreeByParentUid", () => ({ + default: vi.fn(), +})); +vi.mock("roamjs-components/writes/deleteBlock", () => ({ default: vi.fn() })); +vi.mock("~/utils/importedSourceIdentity", () => ({ + findImportedNodeUidBySourceRid: vi.fn(), + writeImportedSourceIdentity: vi.fn(), +})); + +const mockedGetPageTitleByPageUid = vi.mocked(getPageTitleByPageUid); +const mockedGetPageUidByPageTitle = vi.mocked(getPageUidByPageTitle); +const mockedGetShallowTreeByParentUid = vi.mocked(getShallowTreeByParentUid); +const mockedDeleteBlock = vi.mocked(deleteBlock); +const mockedFindImportedNodeUidBySourceRid = vi.mocked( + findImportedNodeUidBySourceRid, +); +const mockedWriteImportedSourceIdentity = vi.mocked( + writeImportedSourceIdentity, +); + +const EXISTING_PAGE_UID = "existing-page-uid"; +const GENERATED_PAGE_UID = "generated-page-uid"; + +const pageFromMarkdown = vi.fn(); +const blockFromMarkdown = vi.fn(); +const pageCreate = vi.fn(); +const pageDelete = vi.fn(); +const updatePage = vi.fn(); + +const sharedNode: SharedNode = { + rid: "orn:obsidian.note:vault-a/node-1", + sourceLocalId: "node-1", + spaceId: 20, + spaceName: "Research vault", + spaceUri: "obsidian:vault-a", + platform: "Obsidian", + title: "EVD - REM sleep and recall", + created: "2026-06-14T12:30:00.000Z", + lastModified: "2026-06-14T15:00:00.000Z", + authorId: 7, + directMetadata: null, +}; + +const FULL_MARKDOWN = [ + "---", + "nodeTypeId: evidence", + "---", + "", + "# Findings", + "REM sleep improves recall", +].join("\n"); + +const MATERIALIZED_MARKDOWN = "# Findings\nREM sleep improves recall"; + +const clientWithFullContent = ({ + text, + contentType = "text/obsidian+markdown", + error, +}: { + text?: string | null; + contentType?: string | null; + error?: { message: string }; +}): { + client: DGSupabaseClient; + eq: ReturnType; + from: ReturnType; +} => { + const maybeSingle = vi.fn().mockResolvedValue( + error + ? { data: null, error } + : { + data: text === undefined ? null : { text, content_type: contentType }, + error: null, + }, + ); + const eq = vi.fn(); + const chain = { eq, maybeSingle }; + eq.mockReturnValue(chain); + const from = vi + .fn() + .mockReturnValue({ select: vi.fn().mockReturnValue(chain) }); + return { client: { from } as unknown as DGSupabaseClient, eq, from }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { + updatePage, + util: { generateUID: vi.fn(() => GENERATED_PAGE_UID) }, + data: { + block: { fromMarkdown: blockFromMarkdown }, + page: { + fromMarkdown: pageFromMarkdown, + create: pageCreate, + delete: pageDelete, + }, + }, + }, + }; + mockedGetShallowTreeByParentUid.mockReturnValue([]); + mockedGetPageUidByPageTitle.mockReturnValue(""); + mockedFindImportedNodeUidBySourceRid.mockResolvedValue(null); +}); + +describe("materializeSharedNode", () => { + it("creates a Roam page from the markdown body and stores source identity", async () => { + const { client, eq } = clientWithFullContent({ text: FULL_MARKDOWN }); + + await expect( + materializeSharedNode({ client, sharedNode }), + ).resolves.toEqual({ + success: true, + action: "created", + pageUid: GENERATED_PAGE_UID, + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + }); + expect(eq).toHaveBeenCalledWith("original", true); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: sharedNode.title, uid: GENERATED_PAGE_UID }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + expect(mockedWriteImportedSourceIdentity).toHaveBeenCalledWith({ + pageUid: GENERATED_PAGE_UID, + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + }); + }); + + it("stores the source modified time as canonical UTC", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + + const result = await materializeSharedNode({ + client, + sharedNode: { ...sharedNode, lastModified: "2026-06-14T17:00:00+02:00" }, + }); + + expect(result.sourceModifiedAt).toBe("2026-06-14T15:00:00.000Z"); + expect(mockedWriteImportedSourceIdentity).toHaveBeenCalledWith( + expect.objectContaining({ sourceModifiedAt: "2026-06-14T15:00:00.000Z" }), + ); + }); + + it("preserves the indentation of a leading indented code block", async () => { + const { client } = clientWithFullContent({ + text: ["---", "nodeTypeId: evidence", "---", "", " const a = 1;"].join( + "\n", + ), + }); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result.success).toBe(true); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: sharedNode.title, uid: GENERATED_PAGE_UID }, + "markdown-string": " const a = 1;", + }); + }); + + it("creates a title-only page when the body is only whitespace", async () => { + const { client } = clientWithFullContent({ + text: ["---", "nodeTypeId: evidence", "---", "", " ", ""].join("\n"), + }); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result.success).toBe(true); + expect(pageCreate).toHaveBeenCalledWith({ + page: { title: sharedNode.title, uid: GENERATED_PAGE_UID }, + }); + expect(pageFromMarkdown).not.toHaveBeenCalled(); + }); + + it("creates a title-only page when the node has no full content", async () => { + const { client } = clientWithFullContent({}); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result.success).toBe(true); + expect(pageCreate).toHaveBeenCalledWith({ + page: { title: sharedNode.title, uid: GENERATED_PAGE_UID }, + }); + expect(pageFromMarkdown).not.toHaveBeenCalled(); + }); + + it("replaces the existing imported page instead of creating a duplicate", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID); + mockedGetPageTitleByPageUid.mockReturnValue(sharedNode.title); + mockedGetShallowTreeByParentUid.mockReturnValue([ + { uid: "old-block", text: "stale" }, + ]); + + await expect( + materializeSharedNode({ client, sharedNode }), + ).resolves.toEqual({ + success: true, + action: "updated", + pageUid: EXISTING_PAGE_UID, + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + }); + expect(pageFromMarkdown).not.toHaveBeenCalled(); + expect(updatePage).not.toHaveBeenCalled(); + expect(blockFromMarkdown).toHaveBeenCalledWith({ + location: { "parent-uid": EXISTING_PAGE_UID, order: "last" }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + expect(mockedDeleteBlock).toHaveBeenCalledWith("old-block"); + expect(blockFromMarkdown.mock.invocationCallOrder[0]).toBeLessThan( + mockedDeleteBlock.mock.invocationCallOrder[0], + ); + }); + + it("renames the imported page when the source title changed", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID); + mockedGetPageTitleByPageUid.mockReturnValue("EVD - old title"); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result.success).toBe(true); + expect(updatePage).toHaveBeenCalledWith({ + page: { uid: EXISTING_PAGE_UID, title: sharedNode.title }, + }); + }); + + it("refuses to clobber a page that was not imported from this source", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedGetPageUidByPageTitle.mockReturnValue("unrelated-page-uid"); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result).toMatchObject({ + success: false, + sourceNodeRid: sharedNode.rid, + error: { stage: "title-collision" }, + }); + expect(pageFromMarkdown).not.toHaveBeenCalled(); + expect(mockedWriteImportedSourceIdentity).not.toHaveBeenCalled(); + }); + + it("fails the rename before touching content when the new title collides", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID); + mockedGetPageTitleByPageUid.mockReturnValue("EVD - old title"); + mockedGetPageUidByPageTitle.mockReturnValue("unrelated-page-uid"); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result).toMatchObject({ + success: false, + pageUid: EXISTING_PAGE_UID, + error: { stage: "title-collision" }, + }); + expect(blockFromMarkdown).not.toHaveBeenCalled(); + expect(mockedDeleteBlock).not.toHaveBeenCalled(); + expect(updatePage).not.toHaveBeenCalled(); + expect(mockedWriteImportedSourceIdentity).not.toHaveBeenCalled(); + }); + + it("rejects a non-Obsidian source before fetching content", async () => { + const { client, from } = clientWithFullContent({ text: FULL_MARKDOWN }); + + const result = await materializeSharedNode({ + client, + sharedNode: { ...sharedNode, platform: "Roam" }, + }); + + expect(result).toMatchObject({ + success: false, + error: { stage: "validate-input" }, + }); + expect(result.success === false && result.error.message).toContain("Roam"); + expect(from).not.toHaveBeenCalled(); + }); + + it("rejects a source identifier that is not a RID", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + + const result = await materializeSharedNode({ + client, + sharedNode: { ...sharedNode, rid: "not-a-rid" }, + }); + + expect(result).toMatchObject({ + success: false, + sourceNodeRid: "not-a-rid", + error: { stage: "validate-input" }, + }); + }); + + it("rejects an invalid source modified time", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + + const result = await materializeSharedNode({ + client, + sharedNode: { ...sharedNode, lastModified: "not-a-date" }, + }); + + expect(result).toMatchObject({ + success: false, + error: { stage: "validate-input" }, + }); + }); + + it("rejects a full content type Roam cannot materialize", async () => { + const { client } = clientWithFullContent({ + text: `# ${sharedNode.title}\n\nbody`, + contentType: "text/markdown", + }); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result).toMatchObject({ + success: false, + error: { stage: "fetch-content" }, + }); + expect(result.success === false && result.error.message).toContain( + "text/markdown", + ); + expect(pageFromMarkdown).not.toHaveBeenCalled(); + }); + + it("fails with the fetch error and keeps identity in the result", async () => { + const { client } = clientWithFullContent({ + error: { message: "permission denied" }, + }); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result).toMatchObject({ + success: false, + sourceNodeRid: sharedNode.rid, + error: { stage: "fetch-content" }, + }); + expect(result.success === false && result.error.message).toContain( + "permission denied", + ); + expect(mockedWriteImportedSourceIdentity).not.toHaveBeenCalled(); + }); + + it("removes a new page when its source identity cannot be stored", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedWriteImportedSourceIdentity.mockRejectedValue( + new Error("props write failed"), + ); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result).toMatchObject({ + success: false, + error: { stage: "write-source-identity" }, + }); + expect(pageDelete).toHaveBeenCalledWith({ + page: { uid: GENERATED_PAGE_UID }, + }); + expect(result.success === false && result.pageUid).toBeUndefined(); + }); + + it("reports the orphaned page uid when cleanup also fails", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedWriteImportedSourceIdentity.mockRejectedValue( + new Error("props write failed"), + ); + pageDelete.mockRejectedValue(new Error("delete failed")); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result).toMatchObject({ + success: false, + pageUid: GENERATED_PAGE_UID, + error: { stage: "write-source-identity" }, + }); + }); + + it("keeps the updated page when refreshing identity fails on re-import", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID); + mockedGetPageTitleByPageUid.mockReturnValue(sharedNode.title); + mockedWriteImportedSourceIdentity.mockRejectedValue( + new Error("props write failed"), + ); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result).toMatchObject({ + success: false, + pageUid: EXISTING_PAGE_UID, + error: { stage: "write-source-identity" }, + }); + expect(pageDelete).not.toHaveBeenCalled(); + }); + + it("reports a failed lookup of the existing import", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockRejectedValue( + new Error("datalog query failed"), + ); + + const result = await materializeSharedNode({ client, sharedNode }); + + expect(result).toMatchObject({ + success: false, + error: { stage: "find-imported-node" }, + }); + expect(pageFromMarkdown).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/roam/src/utils/importedSourceIdentity.ts b/apps/roam/src/utils/importedSourceIdentity.ts index 17f16eb10..64588918a 100644 --- a/apps/roam/src/utils/importedSourceIdentity.ts +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -1,7 +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"; +import { setBlockPropsAsync } from "./setBlockProps"; export type ImportedSourceIdentity = { sourceModifiedAt: string; @@ -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 setBlockPropsAsync(pageUid, { [DISCOURSE_GRAPH_PROP_NAME]: { ...discourseGraphProps, [IMPORTED_FROM_PROP_KEY]: { diff --git a/apps/roam/src/utils/materializeSharedNode.ts b/apps/roam/src/utils/materializeSharedNode.ts new file mode 100644 index 000000000..5646db8d1 --- /dev/null +++ b/apps/roam/src/utils/materializeSharedNode.ts @@ -0,0 +1,332 @@ +import { + contentTypes, + stripFrontmatter, + trimBlankLines, +} from "@repo/content-model"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import { isRid } from "@repo/database/lib/rid"; +import type { SharedNode } from "@repo/database/lib/sharedNodes"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeByParentUid"; +import deleteBlock from "roamjs-components/writes/deleteBlock"; +import { + findImportedNodeUidBySourceRid, + writeImportedSourceIdentity, +} from "./importedSourceIdentity"; + +type MaterializationStage = + | "validate-input" + | "fetch-content" + | "find-imported-node" + | "title-collision" + | "create-page" + | "replace-page-content" + | "update-page-title" + | "write-source-identity"; + +type SourceIdentity = { + sourceModifiedAt: string; + sourceNodeRid: string; +}; + +type MaterializationFailure = SourceIdentity & { + success: false; + pageUid?: string; + error: { + message: string; + stage: MaterializationStage; + }; +}; + +type MaterializationSuccess = SourceIdentity & { + success: true; + action: "created" | "updated"; + pageUid: string; +}; + +export type MaterializeSharedNodeResult = + | MaterializationFailure + | MaterializationSuccess; + +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; + }; +}; + +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: SourceIdentity; + message: string; + pageUid?: string; + stage: MaterializationStage; +}): MaterializationFailure => ({ + ...identity, + success: false, + ...(pageUid ? { pageUid } : {}), + error: { + message: error ? `${message}: ${getErrorMessage(error)}` : message, + stage, + }, +}); + +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` }; + + const modifiedAt = new Date(sharedNode.lastModified); + if (Number.isNaN(modifiedAt.getTime())) + return { + error: `Source modified time "${sharedNode.lastModified}" is invalid`, + }; + + const title = sharedNode.title.trim(); + if (!title) return { error: "Source node title is required" }; + + return { sourceModifiedAt: modifiedAt.toISOString(), title }; +}; + +const fetchFullMarkdown = async ({ + client, + sharedNode, +}: { + client: DGSupabaseClient; + sharedNode: SharedNode; +}): Promise<{ markdown: string } | { error: string }> => { + const { data, error } = await client + .from("my_contents") + .select("text, content_type") + .eq("space_id", sharedNode.spaceId) + .eq("source_local_id", sharedNode.sourceLocalId) + .eq("variant", "full") + .eq("original", true) + .maybeSingle(); + if (error) return { error: error.message }; + if (!data?.text) return { markdown: "" }; + if (data.content_type !== contentTypes.obsidianMarkdown) + return { + error: `Unsupported full content type "${data.content_type}" — expected "${contentTypes.obsidianMarkdown}"`, + }; + const markdown = trimBlankLines(stripFrontmatter(data.text)); + return { markdown: markdown.trim() ? markdown : "" }; +}; + +const createImportedPage = async ({ + identity, + markdown, + title, +}: { + identity: SourceIdentity; + markdown: string; + title: string; +}): Promise => { + if (getPageUidByPageTitle(title)) + return failure({ + identity, + message: `A page titled "${title}" already exists and was not imported from "${identity.sourceNodeRid}". Rename or remove that page, then import again`, + stage: "title-collision", + }); + + const pageUid = window.roamAlphaAPI.util.generateUID(); + try { + if (markdown) { + await getRoamMarkdownApi().page.fromMarkdown({ + page: { title, uid: pageUid }, + "markdown-string": markdown, + }); + } else { + await window.roamAlphaAPI.data.page.create({ + page: { title, uid: pageUid }, + }); + } + } catch (error) { + return failure({ + error, + identity, + message: `Failed to create a Roam page for "${title}"`, + 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 + ? `Created "${title}" but could not store its source identity, and removing the page failed too (${getErrorMessage(cleanupError)}) — delete the page manually before re-importing` + : `Created "${title}" and removed it again because its source identity could not be stored`, + ...(cleanupError ? { pageUid } : {}), + stage: "write-source-identity", + }); + } + + return { ...identity, success: true, action: "created", pageUid }; +}; + +const updateImportedPage = async ({ + identity, + markdown, + pageUid, + title, +}: { + identity: SourceIdentity; + markdown: string; + pageUid: string; + title: string; +}): Promise => { + const localTitle = getPageTitleByPageUid(pageUid); + const needsRename = localTitle !== title; + if (needsRename && getPageUidByPageTitle(title)) + return failure({ + identity, + message: `Cannot rename the imported page "${localTitle}" to "${title}": another page already has that title. Rename or remove that page, then import again`, + pageUid, + stage: "title-collision", + }); + + try { + const previousChildren = getShallowTreeByParentUid(pageUid); + if (markdown) { + await getRoamMarkdownApi().block.fromMarkdown({ + location: { "parent-uid": pageUid, order: "last" }, + "markdown-string": markdown, + }); + } + await Promise.all(previousChildren.map(({ uid }) => deleteBlock(uid))); + } catch (error) { + return failure({ + error, + identity, + message: `Failed to replace the content of "${localTitle}"`, + pageUid, + stage: "replace-page-content", + }); + } + + if (needsRename) { + try { + await window.roamAlphaAPI.updatePage({ page: { uid: pageUid, title } }); + } catch (error) { + return failure({ + error, + identity, + message: `Content of "${localTitle}" was replaced, but the page could not be renamed to "${title}"`, + pageUid, + stage: "update-page-title", + }); + } + } + + try { + await writeImportedSourceIdentity({ pageUid, ...identity }); + } catch (error) { + return failure({ + error, + identity, + message: `Content of "${title}" was replaced, but its source identity could not be refreshed`, + pageUid, + stage: "write-source-identity", + }); + } + + return { ...identity, success: true, action: "updated", pageUid }; +}; + +export const materializeSharedNode = async ({ + client, + sharedNode, +}: { + client: DGSupabaseClient; + sharedNode: SharedNode; +}): Promise => { + const rawIdentity: SourceIdentity = { + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + }; + + const validated = validateSharedNode(sharedNode); + if ("error" in validated) + return failure({ + identity: rawIdentity, + message: validated.error, + stage: "validate-input", + }); + + const identity: SourceIdentity = { + sourceModifiedAt: validated.sourceModifiedAt, + sourceNodeRid: sharedNode.rid, + }; + + const content = await fetchFullMarkdown({ client, sharedNode }).catch( + (error: unknown) => ({ error: getErrorMessage(error) }), + ); + if ("error" in content) + return failure({ + identity, + message: `Could not fetch the content of "${sharedNode.title}" from "${sharedNode.spaceName}": ${content.error}`, + stage: "fetch-content", + }); + + let importedPageUid: string | null; + try { + importedPageUid = await findImportedNodeUidBySourceRid(sharedNode.rid); + } catch (error) { + return failure({ + error, + identity, + message: `Failed to look up an existing import of "${sharedNode.title}"`, + stage: "find-imported-node", + }); + } + + return importedPageUid + ? updateImportedPage({ + identity, + markdown: content.markdown, + pageUid: importedPageUid, + title: validated.title, + }) + : createImportedPage({ + identity, + markdown: content.markdown, + title: validated.title, + }); +}; diff --git a/apps/roam/src/utils/setBlockProps.ts b/apps/roam/src/utils/setBlockProps.ts index 1f8d9d52d..ed3732a29 100644 --- a/apps/roam/src/utils/setBlockProps.ts +++ b/apps/roam/src/utils/setBlockProps.ts @@ -19,11 +19,11 @@ export const deNormalizeProps = (props: json): json => ) : props; -const setBlockProps = ( +export const setBlockPropsAsync = ( 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,19 @@ 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); +}; + +const setBlockProps = ( + uid: string, + newProps: Record, + denormalize: boolean = false, +): void => { + void setBlockPropsAsync(uid, newProps, denormalize); }; export const testSetBlockProps = ( @@ -44,7 +53,7 @@ export const testSetBlockProps = ( newProps: Record, ) => { const uid = getPageUidByPageTitle(title); - return uid ? setBlockProps(uid, newProps) : null; + return uid ? setBlockPropsAsync(uid, newProps) : null; }; export default setBlockProps; 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..ed05450ea --- /dev/null +++ b/packages/content-model/src/__tests__/text.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeLineEndings, + stripFrontmatter, + trimBlankLines, +} from "../text/index.js"; + +describe("normalizeLineEndings", () => { + it("converts CRLF and lone CR to LF", () => { + expect(normalizeLineEndings("a\r\nb\rc\n")).toBe("a\nb\nc\n"); + }); +}); + +describe("trimBlankLines", () => { + it("removes surrounding blank lines but keeps line indentation", () => { + expect(trimBlankLines("\n \n code\n text\n\n \n")).toBe( + " code\n text", + ); + }); + + it("leaves a whitespace-only single line for the caller to judge", () => { + expect(trimBlankLines(" ")).toBe(" "); + }); +}); + +describe("stripFrontmatter", () => { + it("removes a leading YAML frontmatter block and following blank lines", () => { + expect( + stripFrontmatter( + [ + "---", + "nodeTypeId: claim", + "nodeInstanceId: node-1", + "---", + "", + "# Body", + ].join("\n"), + ), + ).toBe("# Body"); + }); + + it("handles CRLF frontmatter", () => { + expect(stripFrontmatter("---\r\nkey: value\r\n---\r\nBody")).toBe("Body"); + }); + + it("returns markdown unchanged when there is no frontmatter", () => { + expect(stripFrontmatter("# Body\ntext")).toBe("# Body\ntext"); + }); + + it("treats a delimiter that never closes as content", () => { + expect(stripFrontmatter("---\nnot frontmatter")).toBe( + "---\nnot frontmatter", + ); + }); + + it("does not treat a mid-document rule as frontmatter", () => { + expect(stripFrontmatter("intro\n---\noutro")).toBe("intro\n---\noutro"); + }); + + it("returns an empty string for frontmatter-only markdown", () => { + expect(stripFrontmatter("---\nkey: value\n---\n")).toBe(""); + }); +}); diff --git a/packages/content-model/src/text/index.ts b/packages/content-model/src/text/index.ts index eed1bf8c7..50f1d7281 100644 --- a/packages/content-model/src/text/index.ts +++ b/packages/content-model/src/text/index.ts @@ -1,2 +1,23 @@ export const normalizeLineEndings = (text: string): string => text.replace(/\r\n?/g, "\n"); + +export const trimBlankLines = (text: string): string => + text.replace(/^(?:[ \t]*\n)+/, "").replace(/(?:\n[ \t]*)+$/, ""); + +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+/, ""); +};