diff --git a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx index a45a52556..f5d0b57d1 100644 --- a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx +++ b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx @@ -1,6 +1,7 @@ import { Button, Callout, + Checkbox, Classes, Dialog, HTMLTable, @@ -17,13 +18,41 @@ import { discoverSharedNodes, type DiscoveredSharedNode, } from "~/utils/discoverSharedNodes"; +import { importDiscoveredSharedNode } from "~/utils/importDiscoveredSharedNode"; +import { + importSelectedSharedNodes, + type SelectedSharedNodeImportResult, +} from "~/utils/importSelectedSharedNodes"; import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext"; const formatModifiedAt = (modifiedAt: string): string => new Date(modifiedAt).toLocaleString(); -const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => ( +const SharedNodeRow = ({ + disabled, + node, + onToggle, + selected, +}: { + disabled: boolean; + node: DiscoveredSharedNode; + onToggle: (node: DiscoveredSharedNode) => void; + selected: boolean; +}) => ( + + onToggle(node)} + title={ + node.sourceApp === "Obsidian" + ? undefined + : "Only Obsidian-origin nodes can be imported into Roam" + } + /> + {node.sourceApp} @@ -76,15 +105,62 @@ const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => ( ); +const ImportResultCallout = ({ + result, +}: { + result: SelectedSharedNodeImportResult; +}) => { + const failedCount = result.failed.length; + const intent = + failedCount === 0 + ? Intent.SUCCESS + : result.imported > 0 || result.skipped > 0 + ? Intent.WARNING + : Intent.DANGER; + + return ( + + {result.updated > 0 && ( +
0 ? "mb-2" : undefined}> + {result.updated} previously imported{" "} + {result.updated === 1 ? "node was" : "nodes were"} updated. +
+ )} + {failedCount > 0 && ( + + )} +
+ ); +}; + const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => { const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [importError, setImportError] = useState(""); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = + useState(); const [search, setSearch] = useState(""); + const [selectedSourceRids, setSelectedSourceRids] = useState>( + new Set(), + ); const loadNodes = useCallback(async (): Promise => { setLoading(true); setError(""); + setImportError(""); + setImportResult(undefined); + setSelectedSourceRids(new Set()); try { const [client, context] = await Promise.all([ getLoggedInClient(), @@ -128,11 +204,88 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => { ); }, [nodes, search]); + const selectedNodes = useMemo( + () => nodes.filter((node) => selectedSourceRids.has(node.sourceNodeRid)), + [nodes, selectedSourceRids], + ); + const visibleImportableNodes = useMemo( + () => visibleNodes.filter((node) => node.sourceApp === "Obsidian"), + [visibleNodes], + ); + const allVisibleSelected = + visibleImportableNodes.length > 0 && + visibleImportableNodes.every((node) => + selectedSourceRids.has(node.sourceNodeRid), + ); + const someVisibleSelected = visibleImportableNodes.some((node) => + selectedSourceRids.has(node.sourceNodeRid), + ); + + const toggleNode = useCallback((node: DiscoveredSharedNode): void => { + setSelectedSourceRids((current) => { + const next = new Set(current); + if (next.has(node.sourceNodeRid)) next.delete(node.sourceNodeRid); + else next.add(node.sourceNodeRid); + return next; + }); + }, []); + + const toggleAllVisible = useCallback((): void => { + setSelectedSourceRids((current) => { + const next = new Set(current); + if (allVisibleSelected) { + visibleImportableNodes.forEach((node) => + next.delete(node.sourceNodeRid), + ); + } else { + visibleImportableNodes.forEach((node) => next.add(node.sourceNodeRid)); + } + return next; + }); + }, [allVisibleSelected, visibleImportableNodes]); + + const importSelected = useCallback(async (): Promise => { + setImporting(true); + setImportError(""); + setImportResult(undefined); + try { + const client = await getLoggedInClient(); + if (!client) throw new Error("Could not connect to shared persistence."); + + const result = await importSelectedSharedNodes({ + materializeNode: (node) => importDiscoveredSharedNode({ client, node }), + nodes: selectedNodes, + }); + const failedSourceRids = new Set( + result.failed.map(({ node }) => node.sourceNodeRid), + ); + setNodes((current) => + current.map((node) => + selectedSourceRids.has(node.sourceNodeRid) && + !failedSourceRids.has(node.sourceNodeRid) + ? { ...node, alreadyImported: true } + : node, + ), + ); + setSelectedSourceRids(failedSourceRids); + setImportResult(result); + } catch (importError) { + console.error("Failed to import selected shared nodes:", importError); + setImportError( + importError instanceof Error + ? importError.message + : "Could not import selected shared nodes.", + ); + } finally { + setImporting(false); + } + }, [selectedNodes, selectedSourceRids]); + return ( void }) => { +
+ + +
diff --git a/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts b/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts index 4a3a9637e..3b852dbb2 100644 --- a/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts +++ b/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts @@ -30,6 +30,7 @@ describe("toDiscoveredSharedNodes", () => { sourceApp: "Obsidian", sourceNodeId: "node-1", sourceNodeRid: "orn:obsidian.note:vault-a/node-1", + sourceSpaceDatabaseId: 20, sourceSpaceId: "obsidian:vault-a", sourceSpaceName: "Research vault", title: "EVD - REM sleep and recall", diff --git a/apps/roam/src/utils/__tests__/importDiscoveredSharedNode.test.ts b/apps/roam/src/utils/__tests__/importDiscoveredSharedNode.test.ts new file mode 100644 index 000000000..e6c55f5ad --- /dev/null +++ b/apps/roam/src/utils/__tests__/importDiscoveredSharedNode.test.ts @@ -0,0 +1,90 @@ +import type { CrossAppNode } from "@repo/database/crossAppContracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DiscoveredSharedNode } from "~/utils/discoverSharedNodes"; +import { importDiscoveredSharedNode } from "~/utils/importDiscoveredSharedNode"; + +const mocks = vi.hoisted(() => ({ + getSharedNodePayload: vi.fn(), + materializeObsidianNode: vi.fn(), +})); + +vi.mock("@repo/database/lib/sharedNodes", () => ({ + getSharedNodePayload: mocks.getSharedNodePayload, +})); +vi.mock("~/utils/materializeObsidianNode", () => ({ + materializeObsidianNode: mocks.materializeObsidianNode, +})); + +const node: DiscoveredSharedNode = { + alreadyImported: false, + modifiedAt: "2026-06-14T15:00:00.000Z", + sourceApp: "Obsidian", + sourceNodeId: "node-1", + sourceNodeRid: "orn:obsidian.note:vault-a/node-1", + sourceSpaceDatabaseId: 20, + sourceSpaceId: "obsidian:vault-a", + sourceSpaceName: "Research vault", + title: "EVD - REM sleep and recall", +}; +const payload = { localId: "node-1" } as CrossAppNode; +const client = {} as Parameters[0]["client"]; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getSharedNodePayload.mockResolvedValue(payload); +}); + +describe("importDiscoveredSharedNode", () => { + it.each([ + ["created", "imported"], + ["updated", "updated"], + ] as const)("maps a %s materialization to %s", async (action, status) => { + mocks.materializeObsidianNode.mockResolvedValue({ + action, + pageUid: "page-uid", + sourceModifiedAt: node.modifiedAt, + sourceNodeRid: node.sourceNodeRid, + success: true, + }); + + await expect(importDiscoveredSharedNode({ client, node })).resolves.toBe( + status, + ); + expect(mocks.getSharedNodePayload).toHaveBeenCalledWith({ + client, + sourceLocalId: "node-1", + spaceId: 20, + }); + expect(mocks.materializeObsidianNode).toHaveBeenCalledWith({ + node: payload, + sourceModifiedAt: node.modifiedAt, + sourceNodeRid: node.sourceNodeRid, + }); + }); + + it("surfaces the materializer's actionable failure message", async () => { + mocks.materializeObsidianNode.mockResolvedValue({ + error: { + message: "Markdown could not be imported", + stage: "create-page", + }, + sourceModifiedAt: node.modifiedAt, + sourceNodeRid: node.sourceNodeRid, + success: false, + }); + + await expect(importDiscoveredSharedNode({ client, node })).rejects.toThrow( + "Markdown could not be imported", + ); + }); + + it("skips non-Obsidian nodes without loading their payload", async () => { + await expect( + importDiscoveredSharedNode({ + client, + node: { ...node, sourceApp: "Roam" }, + }), + ).resolves.toBe("skipped"); + expect(mocks.getSharedNodePayload).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/roam/src/utils/__tests__/importSelectedSharedNodes.test.ts b/apps/roam/src/utils/__tests__/importSelectedSharedNodes.test.ts new file mode 100644 index 000000000..2e8e24b53 --- /dev/null +++ b/apps/roam/src/utils/__tests__/importSelectedSharedNodes.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import type { DiscoveredSharedNode } from "~/utils/discoverSharedNodes"; +import { importSelectedSharedNodes } from "~/utils/importSelectedSharedNodes"; + +const createNode = ({ + sourceNodeRid, + title, +}: { + sourceNodeRid: string; + title: string; +}): DiscoveredSharedNode => ({ + alreadyImported: false, + modifiedAt: "2026-06-14T15:00:00.000Z", + sourceApp: "Obsidian", + sourceNodeId: sourceNodeRid.split("/").at(-1), + sourceNodeRid, + sourceSpaceDatabaseId: 20, + sourceSpaceId: "obsidian:vault-a", + sourceSpaceName: "Research vault", + title, +}); + +const firstNode = createNode({ + sourceNodeRid: "orn:obsidian.note:vault-a/node-1", + title: "First node", +}); +const secondNode = createNode({ + sourceNodeRid: "orn:obsidian.note:vault-a/node-2", + title: "Second node", +}); +const thirdNode = createNode({ + sourceNodeRid: "orn:obsidian.note:vault-a/node-3", + title: "Third node", +}); + +describe("importSelectedSharedNodes", () => { + it("reports imported, updated, and skipped nodes", async () => { + const materializeNode = vi + .fn() + .mockResolvedValueOnce("imported") + .mockResolvedValueOnce("updated") + .mockResolvedValueOnce("skipped"); + + await expect( + importSelectedSharedNodes({ + materializeNode, + nodes: [firstNode, secondNode, thirdNode], + }), + ).resolves.toEqual({ + failed: [], + imported: 2, + skipped: 1, + updated: 1, + }); + expect(materializeNode.mock.calls).toEqual([ + [firstNode], + [secondNode], + [thirdNode], + ]); + }); + + it("continues importing after an individual node fails", async () => { + const materializeNode = vi + .fn() + .mockRejectedValueOnce(new Error("Markdown could not be imported")) + .mockResolvedValueOnce("imported") + .mockRejectedValueOnce("Source content is missing"); + + await expect( + importSelectedSharedNodes({ + materializeNode, + nodes: [firstNode, secondNode, thirdNode], + }), + ).resolves.toEqual({ + failed: [ + { + message: "Markdown could not be imported", + node: firstNode, + }, + { + message: "Source content is missing", + node: thirdNode, + }, + ], + imported: 1, + skipped: 0, + updated: 0, + }); + expect(materializeNode).toHaveBeenCalledTimes(3); + }); + + it("returns empty counts when no nodes are selected", async () => { + const materializeNode = vi.fn(); + + await expect( + importSelectedSharedNodes({ materializeNode, nodes: [] }), + ).resolves.toEqual({ + failed: [], + imported: 0, + skipped: 0, + updated: 0, + }); + expect(materializeNode).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/roam/src/utils/discoverSharedNodes.ts b/apps/roam/src/utils/discoverSharedNodes.ts index f2bf162d8..6585fc26e 100644 --- a/apps/roam/src/utils/discoverSharedNodes.ts +++ b/apps/roam/src/utils/discoverSharedNodes.ts @@ -11,6 +11,7 @@ export type DiscoveredSharedNode = { sourceApp: "Roam" | "Obsidian"; sourceNodeId?: string; sourceNodeRid: string; + sourceSpaceDatabaseId: number; sourceSpaceId: string; sourceSpaceName: string; title: string; @@ -29,6 +30,7 @@ export const toDiscoveredSharedNodes = ({ sourceApp: candidate.platform, sourceNodeId: candidate.sourceLocalId || undefined, sourceNodeRid: candidate.rid, + sourceSpaceDatabaseId: candidate.spaceId, sourceSpaceId: candidate.spaceUri, sourceSpaceName: candidate.spaceName, title: candidate.title, diff --git a/apps/roam/src/utils/importDiscoveredSharedNode.ts b/apps/roam/src/utils/importDiscoveredSharedNode.ts new file mode 100644 index 000000000..2242daab6 --- /dev/null +++ b/apps/roam/src/utils/importDiscoveredSharedNode.ts @@ -0,0 +1,31 @@ +import { getSharedNodePayload } from "@repo/database/lib/sharedNodes"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import type { DiscoveredSharedNode } from "./discoverSharedNodes"; +import type { SharedNodeImportStatus } from "./importSelectedSharedNodes"; +import { materializeObsidianNode } from "./materializeObsidianNode"; + +export const importDiscoveredSharedNode = async ({ + client, + node, +}: { + client: DGSupabaseClient; + node: DiscoveredSharedNode; +}): Promise => { + if (node.sourceApp !== "Obsidian") return "skipped"; + if (!node.sourceNodeId) + throw new Error(`Shared node '${node.sourceNodeRid}' has no source ID`); + + const payload = await getSharedNodePayload({ + client, + sourceLocalId: node.sourceNodeId, + spaceId: node.sourceSpaceDatabaseId, + }); + const result = await materializeObsidianNode({ + node: payload, + sourceModifiedAt: node.modifiedAt, + sourceNodeRid: node.sourceNodeRid, + }); + if (!result.success) throw new Error(result.error.message); + + return result.action === "updated" ? "updated" : "imported"; +}; diff --git a/apps/roam/src/utils/importSelectedSharedNodes.ts b/apps/roam/src/utils/importSelectedSharedNodes.ts new file mode 100644 index 000000000..f286d7795 --- /dev/null +++ b/apps/roam/src/utils/importSelectedSharedNodes.ts @@ -0,0 +1,56 @@ +import type { DiscoveredSharedNode } from "./discoverSharedNodes"; + +export type SharedNodeImportStatus = "imported" | "skipped" | "updated"; + +export type SharedNodeImportFailure = { + message: string; + node: DiscoveredSharedNode; +}; + +export type SelectedSharedNodeImportResult = { + failed: SharedNodeImportFailure[]; + imported: number; + skipped: number; + updated: number; +}; + +export type MaterializeSharedNode = ( + node: DiscoveredSharedNode, +) => Promise; + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "string" && error) return error; + return "Unknown import failure"; +}; + +export const importSelectedSharedNodes = async ({ + materializeNode, + nodes, +}: { + materializeNode: MaterializeSharedNode; + nodes: DiscoveredSharedNode[]; +}): Promise => { + const result: SelectedSharedNodeImportResult = { + failed: [], + imported: 0, + skipped: 0, + updated: 0, + }; + + for (const node of nodes) { + try { + const status = await materializeNode(node); + if (status === "updated") { + result.imported += 1; + result.updated += 1; + } else { + result[status] += 1; + } + } catch (error) { + result.failed.push({ message: getErrorMessage(error), node }); + } + } + + return result; +}; diff --git a/packages/database/src/crossAppContracts.ts b/packages/database/src/crossAppContracts.ts index c996332c3..4166528a1 100644 --- a/packages/database/src/crossAppContracts.ts +++ b/packages/database/src/crossAppContracts.ts @@ -1,4 +1,4 @@ -import type { ContentType } from "@repo/content-model"; +import type { ContentType } from "@repo/content-model/constants"; import { Enums } from "./dbTypes"; export type LocalRef = { diff --git a/packages/database/src/lib/__tests__/sharedNodes.test.ts b/packages/database/src/lib/__tests__/sharedNodes.test.ts index 138a80dba..208815ec5 100644 --- a/packages/database/src/lib/__tests__/sharedNodes.test.ts +++ b/packages/database/src/lib/__tests__/sharedNodes.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { buildSharedNodeCandidates } from "../sharedNodes"; +import { + buildSharedNodeCandidates, + buildSharedNodePayload, +} from "../sharedNodes"; type BuildArgs = Parameters[0]; @@ -159,3 +162,51 @@ describe("buildSharedNodeCandidates", () => { ).toEqual(["node-1", "node-2"]); }); }); + +describe("buildSharedNodePayload", () => { + const concept = { + author_id: 42, + created: "2026-06-14T10:00:00.000Z", + last_modified: "2026-06-14T12:00:00.000Z", + schema_id: 200, + source_local_id: "node-1", + }; + + it("builds the CrossAppNode consumed by the Roam materializer", () => { + expect(buildSharedNodePayload({ concept, contents })).toEqual({ + author: { dbId: 42 }, + content: { + direct: { + contentType: "text/plain", + value: "EVD - REM sleep and recall", + }, + full: { + contentType: "text/markdown", + value: "# EVD - REM sleep and recall", + }, + }, + createdAt: new Date("2026-06-14T10:00:00.000Z"), + localId: "node-1", + modifiedAt: new Date("2026-06-14T15:00:00.000Z"), + nodeType: { dbId: 200 }, + }); + }); + + it("rejects an incomplete payload before it reaches the materializer", () => { + expect(() => + buildSharedNodePayload({ + concept, + contents: contents.filter((content) => content.variant !== "full"), + }), + ).toThrow("Shared node is missing its full content"); + }); + + it("uses the modified time when the source has no created time", () => { + const payload = buildSharedNodePayload({ + concept: { ...concept, created: null }, + contents: contents.map((content) => ({ ...content, created: null })), + }); + + expect(payload.createdAt).toEqual(new Date("2026-06-14T15:00:00.000Z")); + }); +}); diff --git a/packages/database/src/lib/sharedNodes.ts b/packages/database/src/lib/sharedNodes.ts index eb0405f24..d06331d1a 100644 --- a/packages/database/src/lib/sharedNodes.ts +++ b/packages/database/src/lib/sharedNodes.ts @@ -1,3 +1,5 @@ +import { isSupportedContentType } from "@repo/content-model/constants"; +import type { CrossAppNode } from "../crossAppContracts"; import type { DGSupabaseClient } from "./client"; import { getAvailableGroupIds } from "./groups"; import { getAllPages } from "./pagination"; @@ -31,6 +33,19 @@ type SharedSpace = Pick< Tables<"my_spaces">, "id" | "name" | "platform" | "url" >; +type SharedNodePayloadConcept = Pick< + Tables<"my_concepts">, + "author_id" | "created" | "last_modified" | "schema_id" | "source_local_id" +>; +type SharedNodePayloadContent = Pick< + Tables<"my_contents">, + | "author_id" + | "content_type" + | "created" + | "last_modified" + | "text" + | "variant" +>; type ValidSharedSpace = { name: string; platform: "Roam" | "Obsidian"; @@ -58,6 +73,77 @@ export type SharedNodeRows = { spaces: SharedSpace[]; }; +const getValidDate = (values: (string | null)[]): Date | undefined => { + const value = values.find( + (candidate): candidate is string => + typeof candidate === "string" && !Number.isNaN(Date.parse(candidate)), + ); + return value ? new Date(value) : undefined; +}; + +export const buildSharedNodePayload = ({ + concept, + contents, +}: { + concept: SharedNodePayloadConcept; + contents: SharedNodePayloadContent[]; +}): CrossAppNode => { + const direct = contents.find((content) => content.variant === "direct"); + const full = contents.find((content) => content.variant === "full"); + const authorId = concept.author_id ?? direct?.author_id ?? full?.author_id; + const modifiedAt = getValidDate( + [concept.last_modified, direct?.last_modified, full?.last_modified] + .filter((value): value is string => typeof value === "string") + .sort((left, right) => Date.parse(right) - Date.parse(left)), + ); + const sourceCreatedAt = getValidDate([ + concept.created, + direct?.created ?? null, + full?.created ?? null, + ]); + + if (typeof concept.source_local_id !== "string") + throw new Error("Shared node is missing its source-local ID"); + if (typeof concept.schema_id !== "number") + throw new Error("Shared node is missing its node type"); + if (typeof authorId !== "number") + throw new Error("Shared node is missing its author"); + if (!modifiedAt) throw new Error("Shared node is missing its modified time"); + const createdAt = sourceCreatedAt ?? modifiedAt; + if (typeof direct?.text !== "string") + throw new Error("Shared node is missing its direct content"); + if (typeof full?.text !== "string") + throw new Error("Shared node is missing its full content"); + if ( + typeof full.content_type !== "string" || + !isSupportedContentType(full.content_type) + ) + throw new Error( + `Shared node has unsupported full content type '${String(full.content_type)}'`, + ); + + return { + author: { dbId: authorId }, + content: { + direct: { + value: direct.text, + ...(typeof direct.content_type === "string" && + isSupportedContentType(direct.content_type) + ? { contentType: direct.content_type } + : {}), + }, + full: { + contentType: full.content_type, + value: full.text, + }, + }, + createdAt, + localId: concept.source_local_id, + modifiedAt, + nodeType: { dbId: concept.schema_id }, + }; +}; + const getResourceKey = ({ sourceLocalId, spaceId, @@ -317,3 +403,38 @@ export const listGroupSharedNodes = async ({ const rows = await getSharedNodeRows({ client, currentSpaceId, groupIds }); return buildSharedNodeCandidates({ ...rows, currentSpaceId }); }; + +export const getSharedNodePayload = async ({ + client, + sourceLocalId, + spaceId, +}: { + client: DGSupabaseClient; + sourceLocalId: string; + spaceId: number; +}): Promise => { + const [conceptResponse, contentsResponse] = await Promise.all([ + client + .from("my_concepts") + .select("author_id, created, last_modified, schema_id, source_local_id") + .eq("space_id", spaceId) + .eq("source_local_id", sourceLocalId) + .eq("is_schema", false) + .maybeSingle(), + client + .from("my_contents") + .select("author_id, content_type, created, last_modified, text, variant") + .eq("space_id", spaceId) + .eq("source_local_id", sourceLocalId) + .in("variant", ["direct", "full"]), + ]); + if (conceptResponse.error) throw conceptResponse.error; + if (contentsResponse.error) throw contentsResponse.error; + if (!conceptResponse.data) + throw new Error(`Shared node '${sourceLocalId}' is no longer available`); + + return buildSharedNodePayload({ + concept: conceptResponse.data, + contents: contentsResponse.data, + }); +};