diff --git a/apps/obsidian/src/components/ImportNodesModal.tsx b/apps/obsidian/src/components/ImportNodesModal.tsx index c27faf534..c84ef49e1 100644 --- a/apps/obsidian/src/components/ImportNodesModal.tsx +++ b/apps/obsidian/src/components/ImportNodesModal.tsx @@ -4,9 +4,9 @@ import { StrictMode, useState, useEffect, useCallback } from "react"; import type DiscourseGraphPlugin from "../index"; import type { ImportableNode, GroupWithNodes } from "~/types"; import { getUserNameById } from "~/utils/typeUtils"; +import { getAvailableGroupIds } from "@repo/database/lib/groups"; import { fetchUserNames, - getAvailableGroupIds, getPublishedNodesForGroups, getLocalNodeInstanceIds, getSpaceNameFromIds, diff --git a/apps/obsidian/src/components/PublishGroupDropdown.tsx b/apps/obsidian/src/components/PublishGroupDropdown.tsx index fe7655908..93bd6983f 100644 --- a/apps/obsidian/src/components/PublishGroupDropdown.tsx +++ b/apps/obsidian/src/components/PublishGroupDropdown.tsx @@ -11,7 +11,7 @@ import { publishToSelectedGroupWithNotice, withPublishedState, } from "~/utils/publishGroupSelection"; -import type { MyGroup } from "~/utils/importNodes"; +import type { MyGroup } from "@repo/database/lib/groups"; type PublishGroupDropdownProps = { plugin: DiscourseGraphPlugin; diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 1502a3417..9b88ee425 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -21,54 +21,6 @@ import { import { createTemplateFile } from "./templates"; import { resolveFolderForSpaceUri } from "./importFolderMetadata"; -export type MyGroup = { - id: string; - name: string; -}; - -export const getAvailableGroupIds = async ( - client: DGSupabaseClient, -): Promise => { - const { data, error } = await client - .from("group_membership") - .select("group_id") - .eq("member_id", (await client.auth.getUser()).data.user?.id || ""); - - if (error) { - console.error("Error fetching groups:", error); - throw new Error(`Failed to fetch groups: ${error.message}`); - } - - return (data || []).map((g) => g.group_id); -}; - -export const getMyGroups = async ( - client: DGSupabaseClient, -): Promise => { - const userId = (await client.auth.getUser()).data.user?.id ?? ""; - const { data, error } = await client - .from("group_membership") - .select("group_id, my_groups!group_id(name)") - .eq("member_id", userId); - - if (error) { - console.error("Error fetching groups:", error); - throw new Error(`Failed to fetch groups: ${error.message}`); - } - - return (data ?? []) - .filter( - (row): row is { group_id: string; my_groups: { name: string | null } } => - typeof row.group_id === "string" && - row.my_groups !== null && - typeof row.my_groups === "object", - ) - .map((row) => ({ - id: row.group_id, - name: row.my_groups.name ?? row.group_id, - })); -}; - type PublishedNode = { source_local_id: string; space_id: number; diff --git a/apps/obsidian/src/utils/publishGroupSelection.ts b/apps/obsidian/src/utils/publishGroupSelection.ts index 09adcba56..5ed5a82e5 100644 --- a/apps/obsidian/src/utils/publishGroupSelection.ts +++ b/apps/obsidian/src/utils/publishGroupSelection.ts @@ -1,11 +1,11 @@ import { Notice, type FrontMatterCache, type TFile } from "obsidian"; -import type DiscourseGraphPlugin from "~/index"; -import { PublishGroupSuggestModal } from "~/components/PublishGroupSuggestModal"; import { getAvailableGroupIds, getMyGroups, type MyGroup, -} from "~/utils/importNodes"; +} from "@repo/database/lib/groups"; +import type DiscourseGraphPlugin from "~/index"; +import { PublishGroupSuggestModal } from "~/components/PublishGroupSuggestModal"; import { getLoggedInClient } from "~/utils/supabaseContext"; import { getPublishedToGroups, diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index 160a27dab..e18cfecf4 100644 --- a/apps/obsidian/src/utils/publishNode.ts +++ b/apps/obsidian/src/utils/publishNode.ts @@ -11,7 +11,7 @@ import { type RelationsFile, } from "./relationsStore"; import type { RelationInstance } from "~/types"; -import { getAvailableGroupIds } from "./importNodes"; +import { getAvailableGroupIds } from "@repo/database/lib/groups"; import { syncAllNodesAndRelations, syncPublishedNodeAssets, diff --git a/apps/obsidian/src/utils/templateImport.ts b/apps/obsidian/src/utils/templateImport.ts index b4ee988d8..36b6daceb 100644 --- a/apps/obsidian/src/utils/templateImport.ts +++ b/apps/obsidian/src/utils/templateImport.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention -- Supabase query results use snake_case column names */ import type { Json } from "@repo/database/dbTypes"; +import { getAvailableGroupIds } from "@repo/database/lib/groups"; import type DiscourseGraphPlugin from "~/index"; import { fetchUserNames, - getAvailableGroupIds, getSpaceNameFromIds, getSpaceUris, } from "./importNodes"; diff --git a/apps/roam/src/components/Export.tsx b/apps/roam/src/components/Export.tsx index 3e902f3d9..d2964b455 100644 --- a/apps/roam/src/components/Export.tsx +++ b/apps/roam/src/components/Export.tsx @@ -11,6 +11,7 @@ import { Toast, Tooltip, Tab, + Tag, Tabs, RadioGroup, Radio, @@ -84,6 +85,14 @@ import getDiscourseRelations, { } from "~/utils/getDiscourseRelations"; import { AddReferencedNodeType } from "./canvas/DiscourseRelationShape/DiscourseRelationTool"; import posthog from "posthog-js"; +import { getMyGroups, type MyGroup } from "@repo/database/lib/groups"; +import { + getPublishedNodeCountsByGroup, + publishNodesToGroups, + type PublishNode, +} from "~/utils/publishNodesToGroups"; +import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext"; +import { isSyncEnabled } from "~/components/settings/utils/accessors"; const ExportProgress = ({ id }: { id: string }) => { const [progress, setProgress] = useState(0); @@ -121,7 +130,7 @@ export type ExportDialogProps = { title?: string; columns?: Column[]; isExportDiscourseGraph?: boolean; - initialPanel?: "sendTo" | "export"; + initialPanel?: "sendTo" | "export" | "publish"; }; type ExportDialogComponent = ( @@ -134,11 +143,57 @@ const EXPORT_DESTINATIONS = [ { id: "github", label: "Send to GitHub", active: true }, ]; const SEND_TO_DESTINATIONS = ["page", "graph"]; +const INITIAL_PANEL_TO_TAB_ID: Record< + NonNullable, + string +> = { + sendTo: "sendto", + export: "export", + publish: "publish", +}; const exportDestinationById = Object.fromEntries( EXPORT_DESTINATIONS.map((ed) => [ed.id, ed]), ); +const getReferencedUids = (uid: string): string[] => { + const result = + (window.roamAlphaAPI?.pull?.("[{:block/refs [:block/uid]}]", [ + ":block/uid", + uid, + ]) as { [":block/refs"]?: { ":block/uid"?: string }[] } | null) || {}; + return (result[":block/refs"] || []).flatMap((ref) => + ref[":block/uid"] ? [ref[":block/uid"]] : [], + ); +}; + +const getResultPublishNodes = (result: Result): PublishNode[] => { + const directNode = findDiscourseNode({ uid: result.uid }); + if (directNode && directNode.backedBy === "user") + return [{ uid: result.uid, type: directNode.type }]; + return getReferencedUids(result.uid).flatMap((uid) => { + const node = findDiscourseNode({ uid }); + return node && node.backedBy === "user" ? [{ uid, type: node.type }] : []; + }); +}; + +type PublishableNodesState = { + publishableNodes: PublishNode[]; + nonDiscourseCount: number; +}; + +type GroupShareState = { + sharedNodeCount: number; + publishableNodeCount: number; + isFullyShared: boolean; + isPartiallyShared: boolean; +}; + +const EMPTY_PUBLISHABLE_NODES_STATE: PublishableNodesState = { + publishableNodes: [], + nonDiscourseCount: 0, +}; + const ExportDialog: ExportDialogComponent = ({ onClose, isOpen, @@ -202,10 +257,12 @@ const ExportDialog: ExportDialogComponent = ({ useState<(typeof SEND_TO_DESTINATIONS)[number]>("page"); const isSendToGraph = activeSendToDestination === "graph"; const [livePages, setLivePages] = useState([]); + const syncEnabled = useMemo(() => isSyncEnabled(), []); const [selectedTabId, setSelectedTabId] = useState("sendto"); useEffect(() => { - if (initialPanel === "export") setSelectedTabId("export"); - }, [initialPanel]); + if (initialPanel === "publish" && !syncEnabled) return; + if (initialPanel) setSelectedTabId(INITIAL_PANEL_TO_TAB_ID[initialPanel]); + }, [initialPanel, syncEnabled]); const [includeDiscourseContext, setIncludeDiscourseContext] = useState(false); const [gitHubAccessToken, setGitHubAccessToken] = useState( getSetting("oauth-github", null), @@ -213,6 +270,57 @@ const ExportDialog: ExportDialogComponent = ({ const [canSendToGitHub, setCanSendToGitHub] = useState(false); + const [myGroups, setMyGroups] = useState([]); + const [groupsLoading, setGroupsLoading] = useState(false); + const [groupsLoaded, setGroupsLoaded] = useState(false); + const [selectedGroupIds, setSelectedGroupIds] = useState([]); + const [sharedNodeCountsByGroupId, setSharedNodeCountsByGroupId] = useState< + Record + >({}); + const [groupsError, setGroupsError] = useState(""); + const [publishError, setPublishError] = useState(""); + + const { publishableNodes, nonDiscourseCount } = + useMemo(() => { + if (!syncEnabled) return EMPTY_PUBLISHABLE_NODES_STATE; + const seen = new Set(); + const publishableNodes: PublishNode[] = []; + let nonDiscourseCount = 0; + for (const result of results) { + const resolved = getResultPublishNodes(result); + if (resolved.length === 0) { + nonDiscourseCount += 1; + continue; + } + for (const node of resolved) { + if (seen.has(node.uid)) continue; + seen.add(node.uid); + publishableNodes.push(node); + } + } + return { publishableNodes, nonDiscourseCount }; + }, [results, syncEnabled]); + + const publishableNodeUids = useMemo( + () => publishableNodes.map((node) => node.uid), + [publishableNodes], + ); + + const getGroupShareState = (groupId: string): GroupShareState => { + const sharedNodeCount = sharedNodeCountsByGroupId[groupId] ?? 0; + const publishableNodeCount = publishableNodeUids.length; + const isFullyShared = + publishableNodeCount > 0 && sharedNodeCount === publishableNodeCount; + const isPartiallyShared = + sharedNodeCount > 0 && sharedNodeCount < publishableNodeCount; + return { + sharedNodeCount, + publishableNodeCount, + isFullyShared, + isPartiallyShared, + }; + }; + const writeFileToRepo = async ({ filename, content, @@ -764,6 +872,126 @@ const ExportDialog: ExportDialogComponent = ({ }); } }; + useEffect(() => { + if ( + !syncEnabled || + !isOpen || + selectedTabId !== "publish" || + groupsLoaded || + groupsLoading + ) + return; + setGroupsLoading(true); + void (async () => { + try { + const client = await getLoggedInClient(); + if (!client) throw new Error("Could not connect to sync."); + const groups = await getMyGroups(client); + const groupIds = groups.map((group) => group.id); + if (publishableNodeUids.length > 0) { + const context = await getSupabaseContext(); + if (!context) throw new Error("Could not connect to sync."); + const sharedNodeCounts = await getPublishedNodeCountsByGroup({ + client, + spaceId: context.spaceId, + groupIds, + nodeUids: publishableNodeUids, + }); + setSharedNodeCountsByGroupId(sharedNodeCounts); + setSelectedGroupIds((prev) => + prev.filter( + (groupId) => + (sharedNodeCounts[groupId] ?? 0) < publishableNodeUids.length, + ), + ); + } else { + setSharedNodeCountsByGroupId({}); + setSelectedGroupIds([]); + } + setMyGroups(groups); + } catch (e) { + setGroupsError((e as Error).message || "Failed to load groups."); + } finally { + setGroupsLoading(false); + setGroupsLoaded(true); + } + })(); + }, [ + syncEnabled, + isOpen, + selectedTabId, + groupsLoaded, + groupsLoading, + publishableNodeUids, + ]); + + const handlePublish = async () => { + setPublishError(""); + setLoading(true); + try { + const client = await getLoggedInClient(); + const context = await getSupabaseContext(); + if (!client || !context) throw new Error("Could not connect to sync."); + const { + publishedNodeUids, + skippedUnsyncedUids, + okGroupIds, + failedGroupIds, + } = await publishNodesToGroups({ + client, + spaceId: context.spaceId, + groupIds: selectedGroupIds, + nodes: publishableNodes, + }); + posthog.capture("Export Dialog: Publish", { + groupCount: okGroupIds.length, + publishedNodeCount: publishedNodeUids.length, + skippedUnsyncedCount: skippedUnsyncedUids.length, + nonDiscourseCount, + failedGroupCount: failedGroupIds.length, + }); + const hasPublishedNodes = publishedNodeUids.length > 0; + const messages = hasPublishedNodes + ? [ + `Published ${publishedNodeUids.length} node${ + publishedNodeUids.length === 1 ? "" : "s" + } to ${okGroupIds.length} group${ + okGroupIds.length === 1 ? "" : "s" + }.`, + ] + : ["No nodes were published."]; + if (skippedUnsyncedUids.length) + messages.push( + `${skippedUnsyncedUids.length} not synced yet — try again shortly.`, + ); + if (nonDiscourseCount) + messages.push(`${nonDiscourseCount} skipped (not discourse nodes).`); + if (failedGroupIds.length) + messages.push( + `${failedGroupIds.length} group${ + failedGroupIds.length === 1 ? "" : "s" + } failed.`, + ); + renderToast({ + content: messages.join(" "), + intent: + failedGroupIds.length || !hasPublishedNodes ? "warning" : "success", + id: "query-builder-publish-success", + }); + if (hasPublishedNodes) onClose(); + } catch (e) { + internalError({ + error: e as Error, + type: "Publish Dialog Failed", + userMessage: + "Looks like there was an error publishing. The team has been notified.", + }); + setPublishError((e as Error).message); + } finally { + setLoading(false); + } + }; + const ExportPanel = ( <>
@@ -1042,6 +1270,93 @@ const ExportDialog: ExportDialogComponent = ({ ); + const PublishPanel = ( + <> +
+ {groupsLoading || !groupsLoaded ? ( +
Loading groups…
+ ) : groupsError ? ( +
{groupsError}
+ ) : myGroups.length === 0 ? ( +
+ You are not a member of any sharing group. +
+ ) : ( + <> + + {myGroups.map((group) => { + const { + sharedNodeCount, + publishableNodeCount, + isFullyShared, + isPartiallyShared, + } = getGroupShareState(group.id); + const isSelected = selectedGroupIds.includes(group.id); + return ( + + {group.name}{" "} + {isFullyShared ? ( + + Already shared + + ) : isPartiallyShared ? ( + + {sharedNodeCount}/{publishableNodeCount} shared + + ) : null} + + } + onChange={(e) => { + if (isFullyShared) return; + const { checked } = e.target as HTMLInputElement; + setSelectedGroupIds((prev) => + checked + ? [...new Set([...prev, group.id])] + : prev.filter((id) => id !== group.id), + ); + }} + /> + ); + })} +
+ {`Publishing ${publishableNodes.length} discourse node${ + publishableNodes.length === 1 ? "" : "s" + }`} + {nonDiscourseCount > 0 && + ` (${nonDiscourseCount} non-discourse result${ + nonDiscourseCount === 1 ? "" : "s" + } will be skipped)`} +
+ + )} +
+
+
+ {publishError} +
+
+ + ); + return ( <> + {syncEnabled && ( + + )} diff --git a/apps/roam/src/components/PublishNodeTitleButton.tsx b/apps/roam/src/components/PublishNodeTitleButton.tsx new file mode 100644 index 000000000..af7c67a5e --- /dev/null +++ b/apps/roam/src/components/PublishNodeTitleButton.tsx @@ -0,0 +1,53 @@ +import { Button } from "@blueprintjs/core"; +import posthog from "posthog-js"; +import React from "react"; +import { handleTitleAdditions } from "~/utils/handleTitleAdditions"; +import { openShareNodeDialog } from "~/utils/openShareNodeDialog"; + +const PUBLISH_TITLE_BUTTON_ATTRIBUTE = "data-roamjs-publish-node-title-button"; + +const PublishNodeTitleButton = ({ + uid, + title, + nodeType, +}: { + uid: string; + title: string; + nodeType: string; +}): JSX.Element => ( +