Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions apps/roam/src/components/DiscoverSharedNodesDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<string> => new Set<string>();

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 (
<div className="flex max-h-96 flex-col gap-4 overflow-y-auto">
{[...bySpace.entries()].map(([spaceName, group]) => (
<div key={spaceName}>
<div className="mb-1 flex items-center gap-2">
<Tag minimal>{group.sourceApp}</Tag>
<strong>{spaceName}</strong>
</div>
<ul className="m-0 list-none p-0">
{group.nodes.map((node) => (
<li
key={node.sourceNodeRid}
className="flex items-center justify-between gap-2 py-1"
>
<span className="truncate">{node.title}</span>
<span className="flex flex-shrink-0 items-center gap-2">
{node.alreadyImported && (
<Tag intent="success" minimal>
Imported
</Tag>
)}
<span className="text-xs opacity-60">
{formatModified(node.sourceModifiedAt)}
</span>
</span>
</li>
))}
</ul>
</div>
))}
</div>
);
};

const DiscoverSharedNodesDialog = ({
isOpen,
onClose,
}: RoamOverlayProps<Record<string, never>>) => {
const [state, setState] = useState<LoadState>({ 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 (
<Dialog isOpen={isOpen} onClose={onClose} title="Discover shared nodes">
<div className={Classes.DIALOG_BODY}>
{state.status === "loading" && (
<div className="flex items-center gap-2">
<Spinner size={20} />
<span>Loading shared nodes…</span>
</div>
)}
{state.status === "error" && (
<Callout intent="danger" title="Could not load shared nodes">
{state.message}
</Callout>
)}
{state.status === "ready" &&
state.groups.length === 0 &&
state.nodes.length === 0 && (
<Callout intent="primary" title="No sharing groups">
You are not a member of any sharing group yet, so there are no
shared nodes to import.
</Callout>
)}
{state.status === "ready" &&
state.groups.length > 0 &&
state.nodes.length === 0 && (
<Callout intent="primary" title="No shared nodes">
No shared nodes from other spaces are available to import yet.
</Callout>
)}
{state.status === "ready" && state.nodes.length > 0 && (
<SharedNodeList nodes={state.nodes} />
)}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={onClose}>Close</Button>
</div>
</div>
</Dialog>
);
};

export const renderDiscoverSharedNodesDialog = (): void => {
renderOverlay({
id: "discover-shared-nodes",
Overlay: DiscoverSharedNodesDialog,
});
};
11 changes: 11 additions & 0 deletions apps/roam/src/components/settings/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -370,6 +373,14 @@ const FeatureFlagsTab = (): React.ReactElement => {
onAfterChange={(checked) => setAdvancedNodeSearchValue(checked)}
/>

<FeatureFlagPanel
title="Cross-app node import"
description="Show the DG: Import - Discover shared nodes command for importing nodes shared from other graphs (Obsidian/Roam). Reload the graph after toggling."
featureKey="Cross-app node import enabled"
value={crossAppImportValue}
onAfterChange={(checked) => setCrossAppImportValue(checked)}
/>

<Alert
isOpen={isConsentAlertOpen}
onConfirm={() => {
Expand Down
2 changes: 2 additions & 0 deletions apps/roam/src/components/settings/utils/zodSchema.example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand All @@ -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,
};

Expand Down
1 change: 1 addition & 0 deletions apps/roam/src/components/settings/utils/zodSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});

Expand Down
117 changes: 117 additions & 0 deletions apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts
Original file line number Diff line number Diff line change
@@ -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 });

Check warning on line 25 in apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts

View workflow job for this annotation

GitHub Actions / eslint (apps/roam)

[eslint (apps/roam)] apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts#L25

Arrow function has too many parameters (5). Maximum allowed is 3 max-params
Raw output
  25:3   warning  Arrow function has too many parameters (5). Maximum allowed is 3                   max-params

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]!;

Check warning on line 45 in apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts

View workflow job for this annotation

GitHub Actions / eslint (apps/roam)

[eslint (apps/roam)] apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts#L45

This assertion is unnecessary since it does not change the type of the expression @typescript-eslint/no-unnecessary-type-assertion
Raw output
  45:18  warning  This assertion is unnecessary since it does not change the type of the expression  @typescript-eslint/no-unnecessary-type-assertion
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");

Check warning on line 97 in apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts

View workflow job for this annotation

GitHub Actions / eslint (apps/roam)

[eslint (apps/roam)] apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts#L97

This assertion is unnecessary since it does not change the type of the expression @typescript-eslint/no-unnecessary-type-assertion
Raw output
  97:12  warning  This assertion is unnecessary since it does not change the type of the expression  @typescript-eslint/no-unnecessary-type-assertion
expect(result[0]!.alreadyImported).toBe(true);

Check warning on line 98 in apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts

View workflow job for this annotation

GitHub Actions / eslint (apps/roam)

[eslint (apps/roam)] apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts#L98

This assertion is unnecessary since it does not change the type of the expression @typescript-eslint/no-unnecessary-type-assertion
Raw output
  98:12  warning  This assertion is unnecessary since it does not change the type of the expression  @typescript-eslint/no-unnecessary-type-assertion
});

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",
]);
});
});
Loading
Loading