diff --git a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx new file mode 100644 index 000000000..53fbe6aba --- /dev/null +++ b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx @@ -0,0 +1,197 @@ +import React, { useEffect, useState } from "react"; +import { + Button, + Callout, + Classes, + Dialog, + Spinner, + Tag, +} from "@blueprintjs/core"; +import renderOverlay, { + RoamOverlayProps, +} from "roamjs-components/util/renderOverlay"; +import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext"; +import { + discoverSharedNodes, + getMyGroups, + type DiscoverableGroup, + type DiscoveredSharedNode, +} from "~/utils/discoverSharedNodes"; + +/** + * Source RIDs of shared nodes already imported into this Roam graph. ENG-1855 ships this + * as an empty seam: Roam has no imported-node store yet (that is ENG-1856), so nothing is + * marked as imported. ENG-1859 points this at the real reader once ENG-1856 persists + * imported source identity. See ENG-1855 Decisions in the project worklog. + */ +const getImportedSourceRids = (): Set => new Set(); + +type LoadState = + | { status: "loading" } + | { status: "error"; message: string } + | { + status: "ready"; + groups: DiscoverableGroup[]; + nodes: DiscoveredSharedNode[]; + }; + +const formatModified = (iso: string): string => { + const ms = new Date(iso).valueOf(); + if (Number.isNaN(ms) || ms === 0) return "Unknown"; + return new Date(ms).toLocaleString(); +}; + +const SharedNodeList = ({ nodes }: { nodes: DiscoveredSharedNode[] }) => { + const bySpace = new Map< + string, + { + sourceApp: DiscoveredSharedNode["sourceApp"]; + nodes: DiscoveredSharedNode[]; + } + >(); + for (const node of nodes) { + const existing = bySpace.get(node.sourceSpaceName); + if (existing) existing.nodes.push(node); + else + bySpace.set(node.sourceSpaceName, { + sourceApp: node.sourceApp, + nodes: [node], + }); + } + + return ( +
+ {[...bySpace.entries()].map(([spaceName, group]) => ( +
+
+ {group.sourceApp} + {spaceName} +
+
    + {group.nodes.map((node) => ( +
  • + {node.title} + + {node.alreadyImported && ( + + Imported + + )} + + {formatModified(node.sourceModifiedAt)} + + +
  • + ))} +
+
+ ))} +
+ ); +}; + +const DiscoverSharedNodesDialog = ({ + isOpen, + onClose, +}: RoamOverlayProps>) => { + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const client = await getLoggedInClient(); + if (!client) { + if (!cancelled) + setState({ + status: "error", + message: "Could not access the Discourse Graph database.", + }); + return; + } + const context = await getSupabaseContext(); + if (!context) { + if (!cancelled) + setState({ + status: "error", + message: "Could not load this graph's workspace context.", + }); + return; + } + const [groups, nodes] = await Promise.all([ + getMyGroups(client), + discoverSharedNodes({ + client, + currentSpaceId: context.spaceId, + importedRids: getImportedSourceRids(), + }), + ]); + if (!cancelled) setState({ status: "ready", groups, nodes }); + } catch (error) { + if (!cancelled) + setState({ + status: "error", + message: + error instanceof Error + ? error.message + : "Unexpected error loading shared nodes.", + }); + } + }; + void load(); + return () => { + cancelled = true; + }; + }, []); + + return ( + +
+ {state.status === "loading" && ( +
+ + Loading shared nodes… +
+ )} + {state.status === "error" && ( + + {state.message} + + )} + {state.status === "ready" && + state.groups.length === 0 && + state.nodes.length === 0 && ( + + You are not a member of any sharing group yet, so there are no + shared nodes to import. + + )} + {state.status === "ready" && + state.groups.length > 0 && + state.nodes.length === 0 && ( + + No shared nodes from other spaces are available to import yet. + + )} + {state.status === "ready" && state.nodes.length > 0 && ( + + )} +
+
+
+ +
+
+
+ ); +}; + +export const renderDiscoverSharedNodesDialog = (): void => { + renderOverlay({ + id: "discover-shared-nodes", + Overlay: DiscoverSharedNodesDialog, + }); +}; diff --git a/apps/roam/src/components/settings/AdminPanel.tsx b/apps/roam/src/components/settings/AdminPanel.tsx index 8c3e97905..bcf8a9862 100644 --- a/apps/roam/src/components/settings/AdminPanel.tsx +++ b/apps/roam/src/components/settings/AdminPanel.tsx @@ -273,6 +273,9 @@ const FeatureFlagsTab = (): React.ReactElement => { const [advancedNodeSearchValue, setAdvancedNodeSearchValue] = useState( getFeatureFlag("Advanced node search enabled"), ); + const [crossAppImportValue, setCrossAppImportValue] = useState( + getFeatureFlag("Cross-app node import enabled"), + ); const syncAlreadyEnabled = duplicateNodeAlertValue || suggestiveOverlayValue; const ensureSyncEnabled = ( @@ -370,6 +373,14 @@ const FeatureFlagsTab = (): React.ReactElement => { onAfterChange={(checked) => setAdvancedNodeSearchValue(checked)} /> + setCrossAppImportValue(checked)} + /> + { diff --git a/apps/roam/src/components/settings/utils/zodSchema.example.ts b/apps/roam/src/components/settings/utils/zodSchema.example.ts index 3b3f3cd1d..fa83e2302 100644 --- a/apps/roam/src/components/settings/utils/zodSchema.example.ts +++ b/apps/roam/src/components/settings/utils/zodSchema.example.ts @@ -89,6 +89,7 @@ const featureFlags: FeatureFlags = { "Enable left sidebar": true, "Duplicate node alert enabled": true, "Suggestive mode overlay enabled": true, + "Cross-app node import enabled": true, "Use new settings store": false, }; @@ -97,6 +98,7 @@ const defaultFeatureFlags: FeatureFlags = { "Enable left sidebar": false, "Duplicate node alert enabled": false, "Suggestive mode overlay enabled": false, + "Cross-app node import enabled": false, "Use new settings store": false, }; diff --git a/apps/roam/src/components/settings/utils/zodSchema.ts b/apps/roam/src/components/settings/utils/zodSchema.ts index 992635fb1..aa6c6b2fa 100644 --- a/apps/roam/src/components/settings/utils/zodSchema.ts +++ b/apps/roam/src/components/settings/utils/zodSchema.ts @@ -159,6 +159,7 @@ export const FeatureFlagsSchema = z.object({ "Enable left sidebar": z.boolean().default(false), "Duplicate node alert enabled": z.boolean().default(false), "Suggestive mode overlay enabled": z.boolean().default(false), + "Cross-app node import enabled": z.boolean().default(false), "Use new settings store": z.boolean().default(false), }); diff --git a/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts b/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts new file mode 100644 index 000000000..ccdca395b --- /dev/null +++ b/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + ridToSpaceUriAndLocalId, + spaceUriAndLocalIdToRid, +} from "@repo/database/lib/rid"; +import { assembleDiscoveredNodes } from "~/utils/discoverSharedNodes"; + +const ROAM_URL = "https://roamresearch.com/#/app/MAPLab"; +const OBSIDIAN_URL = "obsidian:9a8b7c6d5e4f3210"; + +const spaceMeta: Map< + number, + { url: string; name: string | null; platform: string | null } +> = new Map([ + [1, { url: ROAM_URL, name: "MAP Lab", platform: "Roam" }], + [2, { url: OBSIDIAN_URL, name: "Field Vault", platform: "Obsidian" }], +]); + +const row = ( + space_id: number, + source_local_id: string, + variant: string, + text: string | null, + last_modified: string | null, +) => ({ space_id, source_local_id, variant, text, last_modified }); + +describe("assembleDiscoveredNodes", () => { + it("merges direct + full into one node, preferring direct for the title", () => { + const result = assembleDiscoveredNodes({ + contentRows: [ + row(1, "abc", "direct", "Sleep improves memory", "2026-06-12T10:00:00"), + row( + 1, + "abc", + "full", + "# Sleep improves memory\n\nbody", + "2026-06-12T12:00:00", + ), + ], + spaceMetaById: spaceMeta, + importedRids: new Set(), + }); + + expect(result).toHaveLength(1); + const node = result[0]!; + expect(node.title).toBe("Sleep improves memory"); + expect(node.sourceApp).toBe("roam"); + expect(node.sourceSpaceId).toBe(ROAM_URL); + expect(node.sourceSpaceName).toBe("MAP Lab"); + expect(node.sourceNodeId).toBe("abc"); + expect(node.alreadyImported).toBe(false); + expect(node.sourceModifiedAt).toBe( + new Date("2026-06-12T12:00:00Z").toISOString(), + ); + expect(node.sourceNodeRid).toBe(spaceUriAndLocalIdToRid(ROAM_URL, "abc")); + expect(ridToSpaceUriAndLocalId(node.sourceNodeRid)).toEqual({ + spaceUri: ROAM_URL, + sourceLocalId: "abc", + }); + }); + + it("skips title-only nodes that have no full variant (not actually shared)", () => { + const result = assembleDiscoveredNodes({ + contentRows: [ + row(1, "title-only", "direct", "Just a title", "2026-06-12T10:00:00"), + ], + spaceMetaById: spaceMeta, + importedRids: new Set(), + }); + expect(result).toHaveLength(0); + }); + + it("skips nodes whose source space metadata is missing", () => { + const result = assembleDiscoveredNodes({ + contentRows: [ + row(99, "x", "direct", "Title", "2026-06-12T10:00:00"), + row(99, "x", "full", "body", "2026-06-12T10:00:00"), + ], + spaceMetaById: spaceMeta, + importedRids: new Set(), + }); + expect(result).toHaveLength(0); + }); + + it("marks already-imported nodes by source RID", () => { + const contentRows = [ + row(2, "node-1", "direct", "Field note", "2026-06-12T10:00:00"), + row(2, "node-1", "full", "body", "2026-06-12T10:00:00"), + ]; + const rid = spaceUriAndLocalIdToRid(OBSIDIAN_URL, "node-1"); + const result = assembleDiscoveredNodes({ + contentRows, + spaceMetaById: spaceMeta, + importedRids: new Set([rid]), + }); + expect(result).toHaveLength(1); + expect(result[0]!.sourceApp).toBe("obsidian"); + expect(result[0]!.alreadyImported).toBe(true); + }); + + it("sorts nodes by source space name", () => { + const result = assembleDiscoveredNodes({ + contentRows: [ + row(2, "o1", "direct", "Obsidian node", "2026-06-12T10:00:00"), + row(2, "o1", "full", "body", "2026-06-12T10:00:00"), + row(1, "r1", "direct", "Roam node", "2026-06-12T10:00:00"), + row(1, "r1", "full", "body", "2026-06-12T10:00:00"), + ], + spaceMetaById: spaceMeta, + importedRids: new Set(), + }); + expect(result.map((n) => n.sourceSpaceName)).toEqual([ + "Field Vault", + "MAP Lab", + ]); + }); +}); diff --git a/apps/roam/src/utils/discoverSharedNodes.ts b/apps/roam/src/utils/discoverSharedNodes.ts new file mode 100644 index 000000000..8e9c3871e --- /dev/null +++ b/apps/roam/src/utils/discoverSharedNodes.ts @@ -0,0 +1,192 @@ +import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; + +export type DiscoveredSharedNode = { + sourceApp: "roam" | "obsidian"; + sourceSpaceId: string; + sourceSpaceName: string; + sourceNodeId: string; + sourceNodeRid: string; + title: string; + sourceModifiedAt: string; + alreadyImported: boolean; +}; + +export type DiscoverableGroup = { + id: string; + name: string; +}; + +type DiscoveryContentRow = { + source_local_id: string | null; + space_id: number | null; + text: string | null; + last_modified: string | null; + variant: string | null; +}; + +type SpaceMeta = { + url: string; + name: string | null; + platform: string | null; +}; + +const platformToSourceApp = ( + platform: string | null, +): DiscoveredSharedNode["sourceApp"] | null => { + if (platform === "Roam") return "roam"; + if (platform === "Obsidian") return "obsidian"; + return null; +}; + +const dbTimestampToIso = (value: string | null): string | null => { + if (!value) return null; + const ms = new Date(`${value}Z`).valueOf(); + return Number.isNaN(ms) ? null : new Date(ms).toISOString(); +}; + +const latestModifiedIso = (rows: DiscoveryContentRow[]): string | null => { + const stamps = rows + .map((row) => row.last_modified) + .filter((value): value is string => value != null); + if (stamps.length === 0) return null; + const latest = stamps.reduce((a, b) => (a >= b ? a : b)); + return dbTimestampToIso(latest); +}; + +/** + * Assembles raw `my_contents` rows into app-neutral discovered nodes. Pure (no Supabase + * or Roam access) so it can be unit-tested directly. A node is discoverable only when it + * has a `full` markdown variant — the marker that the source app actually shared it + * (ENG-1848) and therefore that it matches the shared cross-app contract. + */ +export const assembleDiscoveredNodes = ({ + contentRows, + spaceMetaById, + importedRids, +}: { + contentRows: DiscoveryContentRow[]; + spaceMetaById: Map; + importedRids: Set; +}): DiscoveredSharedNode[] => { + const byNode = new Map< + string, + { spaceId: number; sourceNodeId: string; rows: DiscoveryContentRow[] } + >(); + for (const row of contentRows) { + if (row.source_local_id == null || row.space_id == null) continue; + const key = `${row.space_id}\t${row.source_local_id}`; + const existing = byNode.get(key); + if (existing) existing.rows.push(row); + else + byNode.set(key, { + spaceId: row.space_id, + sourceNodeId: row.source_local_id, + rows: [row], + }); + } + + const nodes: DiscoveredSharedNode[] = []; + for (const { spaceId, sourceNodeId, rows } of byNode.values()) { + if (!rows.some((row) => row.variant === "full")) continue; + + const meta = spaceMetaById.get(spaceId); + if (!meta) continue; + const sourceApp = platformToSourceApp(meta.platform); + if (!sourceApp) continue; + + const direct = rows.find((row) => row.variant === "direct"); + const full = rows.find((row) => row.variant === "full"); + const title = (direct?.text ?? full?.text ?? "").trim(); + if (!title) continue; + + const sourceNodeRid = spaceUriAndLocalIdToRid(meta.url, sourceNodeId); + nodes.push({ + sourceApp, + sourceSpaceId: meta.url, + sourceSpaceName: meta.name ?? meta.url, + sourceNodeId, + sourceNodeRid, + title, + sourceModifiedAt: latestModifiedIso(rows) ?? new Date(0).toISOString(), + alreadyImported: importedRids.has(sourceNodeRid), + }); + } + + nodes.sort( + (a, b) => + a.sourceSpaceName.localeCompare(b.sourceSpaceName) || + b.sourceModifiedAt.localeCompare(a.sourceModifiedAt) || + a.title.localeCompare(b.title), + ); + return nodes; +}; + +const fetchSpaceMetaById = async ( + client: DGSupabaseClient, + spaceIds: number[], +): Promise> => { + const metaById = new Map(); + if (spaceIds.length === 0) return metaById; + + const { data, error } = await client + .from("my_spaces") + .select("id, name, url, platform") + .in("id", spaceIds); + if (error) { + throw new Error(`Failed to load source spaces: ${error.message}`); + } + + for (const row of data ?? []) { + if (row.id == null || row.url == null) continue; + metaById.set(row.id, { + url: row.url, + name: row.name, + platform: row.platform, + }); + } + return metaById; +}; + +export const discoverSharedNodes = async ({ + client, + currentSpaceId, + importedRids = new Set(), +}: { + client: DGSupabaseClient; + currentSpaceId: number; + importedRids?: Set; +}): Promise => { + const { data, error } = await client + .from("my_contents") + .select("source_local_id, space_id, text, last_modified, variant") + .neq("space_id", currentSpaceId); + if (error) { + throw new Error(`Failed to load shared nodes: ${error.message}`); + } + + const contentRows = data ?? []; + if (contentRows.length === 0) return []; + + const spaceIds = [ + ...new Set( + contentRows + .map((row) => row.space_id) + .filter((value): value is number => value != null), + ), + ]; + const spaceMetaById = await fetchSpaceMetaById(client, spaceIds); + return assembleDiscoveredNodes({ contentRows, spaceMetaById, importedRids }); +}; + +export const getMyGroups = async ( + client: DGSupabaseClient, +): Promise => { + const { data, error } = await client.from("my_groups").select("id, name"); + if (error) { + throw new Error(`Failed to load sharing groups: ${error.message}`); + } + return (data ?? []).flatMap((row) => + row.id == null ? [] : [{ id: row.id, name: row.name ?? row.id }], + ); +}; diff --git a/apps/roam/src/utils/registerCommandPaletteCommands.ts b/apps/roam/src/utils/registerCommandPaletteCommands.ts index 96d5433f8..434a6156f 100644 --- a/apps/roam/src/utils/registerCommandPaletteCommands.ts +++ b/apps/roam/src/utils/registerCommandPaletteCommands.ts @@ -13,6 +13,7 @@ import fireQuery from "./fireQuery"; import { excludeDefaultNodes } from "~/utils/getDiscourseNodes"; import { render as renderSettings } from "~/components/settings/Settings"; import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog"; +import { renderDiscoverSharedNodesDialog } from "~/components/DiscoverSharedNodesDialog"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; import { getOverlayHandler, @@ -363,6 +364,11 @@ export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => { ); void addCommand("DG: Export - Current page", exportCurrentPage); void addCommand("DG: Export - Discourse graph", exportDiscourseGraph); + if (getFeatureFlag("Cross-app node import enabled")) { + void addCommand("DG: Import - Discover shared nodes", () => + renderDiscoverSharedNodesDialog(), + ); + } void addCommand("DG: Open - Discourse settings", renderSettingsPopup); if (getFeatureFlag("Advanced node search enabled")) { void addCommand("DG: Open Node Search", () => {