-
Notifications
You must be signed in to change notification settings - Fork 6
ENG-1855 Add Roam shared-node import discovery #1214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
da4d214
b805ddf
6d2b17a
aba903d
9f7f93e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| import { | ||
| Button, | ||
| Callout, | ||
| Classes, | ||
| Dialog, | ||
| HTMLTable, | ||
| InputGroup, | ||
| Intent, | ||
| NonIdealState, | ||
| Spinner, | ||
| Tag, | ||
| Tooltip, | ||
| } from "@blueprintjs/core"; | ||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | ||
| import createOverlayRender from "roamjs-components/util/createOverlayRender"; | ||
| import { | ||
| discoverSharedNodes, | ||
| type DiscoveredSharedNode, | ||
| } from "~/utils/discoverSharedNodes"; | ||
| import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext"; | ||
|
|
||
| const formatModifiedAt = (modifiedAt: string): string => | ||
| new Date(modifiedAt).toLocaleString(); | ||
|
|
||
| const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => ( | ||
| <tr> | ||
| <td> | ||
| <Tag minimal>{node.sourceApp}</Tag> | ||
| </td> | ||
| <td> | ||
| <div className="max-w-52 font-medium [overflow-wrap:anywhere]"> | ||
| {node.sourceSpaceName} | ||
| </div> | ||
| <div | ||
| className={[ | ||
| Classes.MONOSPACE_TEXT, | ||
| Classes.TEXT_MUTED, | ||
| "max-w-52 truncate text-xs", | ||
| ].join(" ")} | ||
| title={node.sourceSpaceId} | ||
| > | ||
| {node.sourceSpaceId} | ||
| </div> | ||
| </td> | ||
| <td> | ||
| <div className="max-w-72 font-medium [overflow-wrap:anywhere]"> | ||
| {node.title} | ||
| </div> | ||
| </td> | ||
| <td> | ||
| {node.sourceNodeId ? ( | ||
| <div | ||
| className={[Classes.MONOSPACE_TEXT, "max-w-44 truncate text-xs"].join( | ||
| " ", | ||
| )} | ||
| title={node.sourceNodeRid} | ||
| > | ||
| {node.sourceNodeId} | ||
| </div> | ||
| ) : ( | ||
| <span className={Classes.TEXT_MUTED}>Not provided</span> | ||
| )} | ||
| </td> | ||
| <td className="whitespace-nowrap" title={node.modifiedAt}> | ||
| {formatModifiedAt(node.modifiedAt)} | ||
| </td> | ||
| <td> | ||
| {node.alreadyImported ? ( | ||
| <Tag intent={Intent.SUCCESS} minimal> | ||
| Imported | ||
| </Tag> | ||
| ) : ( | ||
| <Tag minimal>Available</Tag> | ||
| )} | ||
| </td> | ||
| </tr> | ||
| ); | ||
|
|
||
| const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => { | ||
| const [nodes, setNodes] = useState<DiscoveredSharedNode[]>([]); | ||
| const [loading, setLoading] = useState(true); | ||
| const [error, setError] = useState(""); | ||
| const [search, setSearch] = useState(""); | ||
|
|
||
| const loadNodes = useCallback(async (): Promise<void> => { | ||
| setLoading(true); | ||
| setError(""); | ||
| try { | ||
| const [client, context] = await Promise.all([ | ||
| getLoggedInClient(), | ||
| getSupabaseContext(), | ||
| ]); | ||
| if (!client || !context) | ||
| throw new Error("Could not connect to shared persistence."); | ||
| setNodes( | ||
| await discoverSharedNodes({ | ||
| client, | ||
| currentSpaceId: context.spaceId, | ||
| }), | ||
| ); | ||
| } catch (loadError) { | ||
| console.error("Failed to discover shared nodes:", loadError); | ||
| setError( | ||
| loadError instanceof Error | ||
| ? loadError.message | ||
| : "Could not load shared nodes.", | ||
| ); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| void loadNodes(); | ||
| }, [loadNodes]); | ||
|
|
||
| const visibleNodes = useMemo(() => { | ||
| const normalizedSearch = search.trim().toLocaleLowerCase(); | ||
| if (!normalizedSearch) return nodes; | ||
| return nodes.filter((node) => | ||
| [ | ||
| node.sourceApp, | ||
| node.sourceSpaceName, | ||
| node.sourceSpaceId, | ||
| node.title, | ||
| node.sourceNodeId, | ||
| ].some((value) => value?.toLocaleLowerCase().includes(normalizedSearch)), | ||
| ); | ||
| }, [nodes, search]); | ||
|
|
||
| return ( | ||
| <Dialog | ||
| autoFocus={false} | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Roam's command palette dispatches commands on Enter keyup. Without |
||
| canEscapeKeyClose | ||
| canOutsideClickClose | ||
| enforceFocus={false} | ||
| style={{ width: "min(68rem, calc(100vw - 2rem))" }} | ||
| isOpen | ||
| onClose={onClose} | ||
| title="Discover shared nodes" | ||
| > | ||
| <div | ||
| className={[Classes.DIALOG_BODY, "flex min-h-72 flex-col gap-3"].join( | ||
| " ", | ||
| )} | ||
| > | ||
| <div className="flex items-center gap-2"> | ||
| <InputGroup | ||
| className="min-w-0 flex-1" | ||
| leftIcon="search" | ||
| onChange={(event: React.ChangeEvent<HTMLInputElement>) => | ||
| setSearch(event.target.value) | ||
| } | ||
| placeholder="Search shared nodes" | ||
| value={search} | ||
| /> | ||
| <Tooltip content="Reload shared nodes"> | ||
| <Button | ||
| aria-label="Reload shared nodes" | ||
| disabled={loading} | ||
| icon="refresh" | ||
| minimal | ||
| onClick={() => void loadNodes()} | ||
| /> | ||
| </Tooltip> | ||
| </div> | ||
|
|
||
| {loading ? ( | ||
| <div className="flex min-h-52 items-center justify-center"> | ||
| <Spinner /> | ||
| </div> | ||
| ) : error ? ( | ||
| <Callout intent={Intent.DANGER} title="Could not load shared nodes"> | ||
| <div className="mb-3">{error}</div> | ||
| <Button icon="refresh" onClick={() => void loadNodes()}> | ||
| Try again | ||
| </Button> | ||
| </Callout> | ||
| ) : visibleNodes.length === 0 ? ( | ||
| <div className="flex min-h-52 items-center justify-center"> | ||
| <NonIdealState | ||
| icon="search" | ||
| title={search ? "No matching shared nodes" : "No shared nodes"} | ||
| /> | ||
| </div> | ||
| ) : ( | ||
| <div className="min-h-0 overflow-auto"> | ||
| <HTMLTable striped className="w-full"> | ||
| <thead> | ||
| <tr> | ||
| <th>Source app</th> | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This stays intentionally read-only: these columns match ENG-1855's pre-import metadata, while materialization remains in ENG-1859. |
||
| <th>Source space</th> | ||
| <th>Title</th> | ||
| <th>Source ID</th> | ||
| <th>Modified</th> | ||
| <th>Status</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {visibleNodes.map((node) => ( | ||
| <SharedNodeRow key={node.sourceNodeRid} node={node} /> | ||
| ))} | ||
| </tbody> | ||
| </HTMLTable> | ||
| </div> | ||
| )} | ||
| </div> | ||
| <div className={Classes.DIALOG_FOOTER}> | ||
| <div className="flex items-center justify-between"> | ||
| <span className={[Classes.TEXT_MUTED, "text-xs"].join(" ")}> | ||
| {loading || error | ||
| ? "" | ||
| : `${visibleNodes.length} of ${nodes.length} nodes`} | ||
| </span> | ||
| <Button onClick={onClose}>Close</Button> | ||
| </div> | ||
| </div> | ||
| </Dialog> | ||
| ); | ||
| }; | ||
|
|
||
| type Props = Record<string, never>; | ||
|
|
||
| export const renderDiscoverSharedNodesDialog = createOverlayRender<Props>( | ||
| "discourse-discover-shared-nodes", | ||
| DiscoverSharedNodesDialog, | ||
| ); | ||
|
|
||
| export default DiscoverSharedNodesDialog; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { buildDiscoveredSharedNodes } from "~/utils/discoverSharedNodes"; | ||
|
|
||
| type BuildArgs = Parameters<typeof buildDiscoveredSharedNodes>[0]; | ||
|
|
||
| const resources: BuildArgs["resources"] = [ | ||
| { space_id: 20, source_local_id: "node-1" }, | ||
| { space_id: 20, source_local_id: "schema-1" }, | ||
| ]; | ||
| const spaces: BuildArgs["spaces"] = [ | ||
| { | ||
| id: 20, | ||
| name: "Research vault", | ||
| platform: "Obsidian", | ||
| url: "obsidian:vault-a", | ||
| }, | ||
| ]; | ||
| const concepts: BuildArgs["concepts"] = [ | ||
| { | ||
| is_schema: false, | ||
| last_modified: "2026-06-14T12:00:00.000Z", | ||
| schema_id: 200, | ||
| source_local_id: "node-1", | ||
| space_id: 20, | ||
| }, | ||
| ]; | ||
| const contents: BuildArgs["contents"] = [ | ||
| { | ||
| content_type: "text/plain", | ||
| last_modified: "2026-06-14T13:00:00.000Z", | ||
| source_local_id: "node-1", | ||
| space_id: 20, | ||
| text: "EVD - REM sleep and recall", | ||
| variant: "direct", | ||
| }, | ||
| { | ||
| content_type: "text/markdown", | ||
| last_modified: "2026-06-14T15:00:00.000Z", | ||
| source_local_id: "node-1", | ||
| space_id: 20, | ||
| text: "# EVD - REM sleep and recall", | ||
| variant: "full", | ||
| }, | ||
| ]; | ||
| const sourceNodeRid = "orn:obsidian.note:vault-a/node-1"; | ||
|
|
||
| const build = ({ | ||
| conceptsOverride = concepts, | ||
| contentsOverride = contents, | ||
| currentSpaceId = 10, | ||
| importedSourceRids = new Set<string>(), | ||
| resourcesOverride = resources, | ||
| spacesOverride = spaces, | ||
| }: { | ||
| conceptsOverride?: typeof concepts; | ||
| contentsOverride?: typeof contents; | ||
| currentSpaceId?: number; | ||
| importedSourceRids?: ReadonlySet<string>; | ||
| resourcesOverride?: typeof resources; | ||
| spacesOverride?: typeof spaces; | ||
| } = {}) => | ||
| buildDiscoveredSharedNodes({ | ||
| concepts: conceptsOverride, | ||
| contents: contentsOverride, | ||
| currentSpaceId, | ||
| importedSourceRids, | ||
| resources: resourcesOverride, | ||
| spaces: spacesOverride, | ||
| }); | ||
|
|
||
| describe("buildDiscoveredSharedNodes", () => { | ||
| it("builds a group-shared contract node with stable source identity", () => { | ||
| expect(build({ importedSourceRids: new Set([sourceNodeRid]) })).toEqual([ | ||
| { | ||
| alreadyImported: true, | ||
| modifiedAt: "2026-06-14T15:00:00.000Z", | ||
| sourceApp: "Obsidian", | ||
| sourceNodeId: "node-1", | ||
| sourceNodeRid, | ||
| sourceSpaceId: "obsidian:vault-a", | ||
| sourceSpaceName: "Research vault", | ||
| title: "EVD - REM sleep and recall", | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("does not discover shared resources from the current space", () => { | ||
| expect(build({ currentSpaceId: 20 })).toEqual([]); | ||
| }); | ||
|
|
||
| it("requires the exact shared resource identity", () => { | ||
| expect( | ||
| build({ | ||
| resourcesOverride: [{ space_id: 21, source_local_id: "node-1" }], | ||
| }), | ||
| ).toEqual([]); | ||
| }); | ||
|
|
||
| it.each([ | ||
| { | ||
| name: "schema concept", | ||
| conceptsOverride: [{ ...concepts[0], is_schema: true }], | ||
| contentsOverride: contents, | ||
| }, | ||
| { | ||
| name: "missing node type", | ||
| conceptsOverride: [{ ...concepts[0], schema_id: null }], | ||
| contentsOverride: contents, | ||
| }, | ||
| { | ||
| name: "missing direct content", | ||
| conceptsOverride: concepts, | ||
| contentsOverride: [contents[1]], | ||
| }, | ||
| { | ||
| name: "missing full content", | ||
| conceptsOverride: concepts, | ||
| contentsOverride: [contents[0]], | ||
| }, | ||
| { | ||
| name: "untyped full content", | ||
| conceptsOverride: concepts, | ||
| contentsOverride: [contents[0], { ...contents[1], content_type: null }], | ||
| }, | ||
| ])("filters a node with $name", ({ conceptsOverride, contentsOverride }) => { | ||
| expect(build({ conceptsOverride, contentsOverride })).toEqual([]); | ||
| }); | ||
|
|
||
| it("matches imports by RID rather than source-local ID alone", () => { | ||
| expect( | ||
| build({ | ||
| importedSourceRids: new Set(["orn:obsidian.note:another-vault/node-1"]), | ||
| })[0]?.alreadyImported, | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("sorts newest nodes first", () => { | ||
| const olderConcept = { | ||
| ...concepts[0], | ||
| last_modified: "2026-06-10T12:00:00.000Z", | ||
| source_local_id: "node-2", | ||
| }; | ||
| const olderContents = contents.map((content) => ({ | ||
| ...content, | ||
| last_modified: "2026-06-10T12:00:00.000Z", | ||
| source_local_id: "node-2", | ||
| text: | ||
| content.variant === "direct" | ||
| ? "Older shared node" | ||
| : "# Older shared node", | ||
| })); | ||
| expect( | ||
| build({ | ||
| conceptsOverride: [olderConcept, concepts[0]], | ||
| contentsOverride: [...olderContents, ...contents], | ||
| resourcesOverride: [ | ||
| ...resources, | ||
| { space_id: 20, source_local_id: "node-2" }, | ||
| ], | ||
| }).map((node) => node.sourceNodeId), | ||
| ).toEqual(["node-1", "node-2"]); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.