diff --git a/apps/obsidian/src/components/DiscourseContextView.tsx b/apps/obsidian/src/components/DiscourseContextView.tsx index 26a32d88a..c9e7c715b 100644 --- a/apps/obsidian/src/components/DiscourseContextView.tsx +++ b/apps/obsidian/src/components/DiscourseContextView.tsx @@ -3,7 +3,6 @@ import { TFile, WorkspaceLeaf, Notice, - FrontMatterCache, setIcon, setTooltip, } from "obsidian"; @@ -19,9 +18,9 @@ import { getUserNameById, } from "~/utils/typeUtils"; import { refreshImportedFile } from "~/utils/importNodes"; -import { publishNode } from "~/utils/publishNode"; +import { PublishGroupDropdown } from "~/components/PublishGroupDropdown"; import { createBaseForNodeType } from "~/utils/baseForNodeType"; -import { useState, useEffect } from "react"; +import { useState } from "react"; type DiscourseContextProps = { activeFile: TFile | null; @@ -45,28 +44,6 @@ export const InfoTooltip = ({ content }: InfoTooltipProps) => ( const DiscourseContext = ({ activeFile }: DiscourseContextProps) => { const plugin = usePlugin(); const [isRefreshing, setIsRefreshing] = useState(false); - const [isPublishing, setIsPublishing] = useState(false); - const [isPublished, setIsPublished] = useState(false); - - useEffect(() => { - if (!activeFile || !plugin) { - setIsPublished(false); - return; - } - const fileMetadata = plugin.app.metadataCache.getFileCache(activeFile); - const frontmatter = fileMetadata?.frontmatter; - if (!frontmatter) { - setIsPublished(false); - return; - } - const isImported = !!frontmatter.importedFromRid; - const publishedToGroups = frontmatter.publishedToGroups as unknown; - const published = - !isImported && - Array.isArray(publishedToGroups) && - publishedToGroups.length > 0; - setIsPublished(published); - }, [activeFile, plugin]); const extractContentFromTitle = (format: string, title: string): string => { if (!format) return ""; @@ -99,29 +76,6 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => { } }; - const handlePublish = async (frontmatter: FrontMatterCache) => { - if (!activeFile || isPublishing) return; - - if (!frontmatter.nodeInstanceId) { - new Notice("Please sync the node first", 5000); - return; - } - - setIsPublishing(true); - try { - await publishNode({ plugin, file: activeFile, frontmatter }); - new Notice("Published successfully", 3000); - setIsPublished(true); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - new Notice(`Publish failed: ${errorMessage}`, 5000); - console.error("Publish failed:", error); - } finally { - setIsPublishing(false); - } - }; - const renderContent = () => { if (!activeFile) { return
No file is open
; @@ -212,28 +166,7 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => { )} {canPublish && ( - + )} diff --git a/apps/obsidian/src/components/PublishGroupDropdown.tsx b/apps/obsidian/src/components/PublishGroupDropdown.tsx new file mode 100644 index 000000000..fe7655908 --- /dev/null +++ b/apps/obsidian/src/components/PublishGroupDropdown.tsx @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import { + getPublishedToGroups, + getPublishToAllTitle, + getUnpublishedGroups, + loadMyGroups, + notifyPublishError, + publishToAllGroupsWithNotice, + publishToSelectedGroupWithNotice, + withPublishedState, +} from "~/utils/publishGroupSelection"; +import type { MyGroup } from "~/utils/importNodes"; + +type PublishGroupDropdownProps = { + plugin: DiscourseGraphPlugin; + file: TFile; +}; + +export const PublishGroupDropdown = ({ + plugin, + file, +}: PublishGroupDropdownProps) => { + const containerRef = useRef(null); + const [groups, setGroups] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); + const [loadError, setLoadError] = useState(null); + const [, setMetadataVersion] = useState(0); + + const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter; + const publishedToGroups = useMemo( + () => (frontmatter ? getPublishedToGroups(frontmatter) : []), + [frontmatter], + ); + const groupsWithPublishedState = withPublishedState( + groups, + publishedToGroups, + ); + const unpublishedGroups = getUnpublishedGroups(groupsWithPublishedState); + + useEffect(() => { + const ref = plugin.app.metadataCache.on("changed", (changedFile) => { + if (changedFile.path === file.path) { + setMetadataVersion((version) => version + 1); + } + }); + + return () => { + plugin.app.metadataCache.offref(ref); + }; + }, [plugin.app.metadataCache, file.path]); + + useEffect(() => { + if (!isOpen) return; + + let cancelled = false; + + const loadGroups = async () => { + setIsLoading(true); + setLoadError(null); + try { + const myGroups = await loadMyGroups(plugin); + if (!cancelled) { + setGroups(myGroups); + } + } catch (error) { + if (!cancelled) { + setLoadError(error instanceof Error ? error.message : String(error)); + setGroups([]); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + void loadGroups(); + + return () => { + cancelled = true; + }; + }, [plugin, isOpen]); + + useEffect(() => { + if (!isOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [isOpen]); + + const runPublishAction = useCallback( + async (action: () => Promise, onSuccess?: () => void) => { + if (isPublishing) return; + + setIsPublishing(true); + try { + await action(); + onSuccess?.(); + } catch (error) { + notifyPublishError(error); + } finally { + setIsPublishing(false); + } + }, + [isPublishing], + ); + + const handlePublishToGroup = useCallback( + (groupId: string) => { + if (publishedToGroups.includes(groupId)) return; + + void runPublishAction(async () => { + await publishToSelectedGroupWithNotice({ plugin, file, groupId }); + setIsOpen(false); + }); + }, + [plugin, file, publishedToGroups, runPublishAction], + ); + + const handlePublishToAllGroups = useCallback(() => { + if (isLoading || unpublishedGroups.length === 0) return; + + void runPublishAction(async () => { + await publishToAllGroupsWithNotice({ plugin, file }); + setIsOpen(false); + }); + }, [plugin, file, isLoading, unpublishedGroups.length, runPublishAction]); + + if (!frontmatter) { + return null; + } + + const publishedCount = publishedToGroups.length; + const triggerLabel = + publishedCount > 0 ? `Published (${publishedCount})` : "Publish"; + + return ( +
+ + + {isOpen && ( +
+
handlePublishToAllGroups()} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handlePublishToAllGroups(); + } + }} + className={`border-b border-gray-200 px-3 py-1.5 text-xs font-medium dark:border-gray-600 ${ + isLoading || isPublishing || unpublishedGroups.length === 0 + ? "cursor-default text-gray-400 dark:text-gray-500" + : "cursor-pointer text-gray-900 hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-800" + }`} + title={getPublishToAllTitle(unpublishedGroups.length)} + > + Publish to all groups +
+ + {isLoading && ( +
+ Loading groups... +
+ )} + + {loadError && ( +
+ {loadError} +
+ )} + + {!isLoading && + !loadError && + groupsWithPublishedState.length === 0 && ( +
+ You are not a member of any groups. +
+ )} + + {!isLoading && + !loadError && + groupsWithPublishedState.map((group) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/apps/obsidian/src/components/PublishGroupSuggestModal.tsx b/apps/obsidian/src/components/PublishGroupSuggestModal.tsx new file mode 100644 index 000000000..0f1f3a7c9 --- /dev/null +++ b/apps/obsidian/src/components/PublishGroupSuggestModal.tsx @@ -0,0 +1,60 @@ +import { App, SuggestModal } from "obsidian"; +import type { PublishGroupSuggestItem } from "~/utils/publishGroupSelection"; + +type PublishGroupSuggestModalParams = { + app: App; + items: PublishGroupSuggestItem[]; + onChoose: (item: PublishGroupSuggestItem) => void | Promise; +}; + +export class PublishGroupSuggestModal extends SuggestModal { + private items: PublishGroupSuggestItem[]; + private onChoose: (item: PublishGroupSuggestItem) => void | Promise; + + constructor({ app, items, onChoose }: PublishGroupSuggestModalParams) { + super(app); + this.items = items; + this.onChoose = onChoose; + this.setPlaceholder("Choose a group to share with"); + } + + getItemText(item: PublishGroupSuggestItem): string { + if (item.isPublishToAll) { + return item.name; + } + return item.isPublished ? `${item.name} (shared)` : item.name; + } + + getSuggestions(query: string): PublishGroupSuggestItem[] { + const normalizedQuery = query.toLowerCase(); + return this.items.filter((item) => + item.name.toLowerCase().includes(normalizedQuery), + ); + } + + renderSuggestion(item: PublishGroupSuggestItem, el: HTMLElement): void { + const row = el.createDiv({ + cls: item.isPublishToAll + ? "border-b border-border pb-1 font-medium" + : "flex items-center gap-2", + }); + + if (item.isPublishToAll) { + row.createSpan({ text: item.name }); + return; + } + + row.createSpan({ + cls: "inline-flex w-4 shrink-0 justify-center", + text: item.isPublished ? "✓" : "", + }); + row.createSpan({ text: item.name }); + } + + onChooseSuggestion(item: PublishGroupSuggestItem): void { + if (item.isPublished) { + return; + } + void this.onChoose(item); + } +} diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 4cb8c7df3..1502a3417 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -21,6 +21,11 @@ import { import { createTemplateFile } from "./templates"; import { resolveFolderForSpaceUri } from "./importFolderMetadata"; +export type MyGroup = { + id: string; + name: string; +}; + export const getAvailableGroupIds = async ( client: DGSupabaseClient, ): Promise => { @@ -37,6 +42,33 @@ export const getAvailableGroupIds = async ( 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 new file mode 100644 index 000000000..09adcba56 --- /dev/null +++ b/apps/obsidian/src/utils/publishGroupSelection.ts @@ -0,0 +1,253 @@ +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"; +import { getLoggedInClient } from "~/utils/supabaseContext"; +import { + getPublishedToGroups, + publishNode, + publishNodeToGroup, +} from "~/utils/publishNode"; +import { syncAllNodesAndRelations } from "~/utils/syncDgNodesToSupabase"; + +export type PublishGroupOption = MyGroup & { + isPublished: boolean; +}; + +export const PUBLISH_TO_ALL_ITEM_ID = "__publish_to_all_groups__"; + +export type PublishGroupSuggestItem = PublishGroupOption & { + isPublishToAll?: boolean; +}; + +export { getPublishedToGroups }; + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +export const notifyPublishError = (error: unknown): void => { + new Notice(`Publish failed: ${getErrorMessage(error)}`, 5000); + console.error("Publish failed:", error); +}; + +export const getUnpublishedGroups = ( + groups: PublishGroupOption[], +): PublishGroupOption[] => groups.filter((group) => !group.isPublished); + +export const getPublishToAllTitle = (unpublishedCount: number): string => + unpublishedCount === 0 + ? "Already published to all groups" + : `Publish to ${unpublishedCount} group${unpublishedCount === 1 ? "" : "s"}`; + +export const buildPublishGroupPickerItems = ( + groups: PublishGroupOption[], +): PublishGroupSuggestItem[] => { + const unpublishedGroups = getUnpublishedGroups(groups); + return [ + { + id: PUBLISH_TO_ALL_ITEM_ID, + name: "Publish to all groups", + isPublished: unpublishedGroups.length === 0, + isPublishToAll: true, + }, + ...groups, + ]; +}; + +export const isPublishToAllItem = ( + item: PublishGroupSuggestItem, +): item is PublishGroupSuggestItem & { isPublishToAll: true } => + item.isPublishToAll === true; + +export const loadMyGroups = async ( + plugin: DiscourseGraphPlugin, +): Promise => { + const client = await getLoggedInClient(plugin); + if (!client) { + throw new Error("Cannot connect to database"); + } + return getMyGroups(client); +}; + +export const withPublishedState = ( + groups: MyGroup[], + publishedToGroups: string[], +): PublishGroupOption[] => + groups.map((group) => ({ + ...group, + isPublished: publishedToGroups.includes(group.id), + })); + +export const publishNodeToSelectedGroup = async ({ + plugin, + file, + frontmatter, + groupId, +}: { + plugin: DiscourseGraphPlugin; + file: TFile; + frontmatter: FrontMatterCache | Record; + groupId: string; +}): Promise => { + const publishedToGroups = getPublishedToGroups(frontmatter); + if (publishedToGroups.includes(groupId)) { + throw new Error("Already shared with this group"); + } + + if (!frontmatter.nodeInstanceId) { + throw new Error("Please sync the node first"); + } + + await publishNode({ + plugin, + file, + frontmatter: frontmatter as FrontMatterCache, + groupId, + }); +}; + +export const publishNodeToAllGroups = async ({ + plugin, + file, +}: { + plugin: DiscourseGraphPlugin; + file: TFile; +}): Promise => { + const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter; + if (!frontmatter) { + throw new Error("File metadata not available"); + } + + const client = await getLoggedInClient(plugin); + if (!client) { + throw new Error("Cannot connect to database"); + } + + const memberGroupIds = await getAvailableGroupIds(client); + const existingPublish = getPublishedToGroups(frontmatter); + const toPublish = memberGroupIds.filter( + (groupId) => !existingPublish.includes(groupId), + ); + + if (toPublish.length === 0) { + return 0; + } + + if (!frontmatter.nodeInstanceId) { + throw new Error("Please sync the node first"); + } + + await syncAllNodesAndRelations(plugin); + + for (const groupId of toPublish) { + await publishNodeToGroup({ + plugin, + file, + frontmatter, + myGroup: groupId, + skipFrontmatterUpdate: true, + }); + } + + await plugin.app.fileManager.processFrontMatter( + file, + (fm: Record) => { + const current = getPublishedToGroups(fm); + fm.publishedToGroups = [...new Set([...current, ...toPublish])]; + }, + ); + + return toPublish.length; +}; + +export const publishToSelectedGroupWithNotice = async ({ + plugin, + file, + groupId, +}: { + plugin: DiscourseGraphPlugin; + file: TFile; + groupId: string; +}): Promise => { + const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter; + if (!frontmatter) { + throw new Error("File metadata not available"); + } + + await publishNodeToSelectedGroup({ + plugin, + file, + frontmatter, + groupId, + }); + new Notice("Published successfully", 3000); +}; + +export const publishToAllGroupsWithNotice = async ({ + plugin, + file, +}: { + plugin: DiscourseGraphPlugin; + file: TFile; +}): Promise => { + const publishedCount = await publishNodeToAllGroups({ plugin, file }); + if (publishedCount === 0) { + new Notice("Already published to all groups", 3000); + return; + } + new Notice( + `Published to ${publishedCount} group${publishedCount === 1 ? "" : "s"}`, + 3000, + ); +}; + +export const openPublishGroupPicker = async ({ + plugin, + file, +}: { + plugin: DiscourseGraphPlugin; + file: TFile; +}): Promise => { + let groups: PublishGroupOption[]; + try { + const myGroups = await loadMyGroups(plugin); + const frontmatter = + plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? {}; + groups = withPublishedState(myGroups, getPublishedToGroups(frontmatter)); + } catch (error) { + new Notice(getErrorMessage(error), 5000); + return; + } + + if (groups.length === 0) { + new Notice("You are not a member of any groups", 5000); + return; + } + + new PublishGroupSuggestModal({ + app: plugin.app, + items: buildPublishGroupPickerItems(groups), + onChoose: async (item: PublishGroupSuggestItem) => { + try { + if (isPublishToAllItem(item)) { + await publishToAllGroupsWithNotice({ plugin, file }); + return; + } + if (item.isPublished) { + return; + } + await publishToSelectedGroupWithNotice({ + plugin, + file, + groupId: item.id, + }); + } catch (error) { + notifyPublishError(error); + } + }, + }).open(); +}; diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index 78e0c86f8..160a27dab 100644 --- a/apps/obsidian/src/utils/publishNode.ts +++ b/apps/obsidian/src/utils/publishNode.ts @@ -23,6 +23,14 @@ import type { DiscourseNodeInVault } from "./getDiscourseNodes"; import type { SupabaseContext } from "./supabaseContext"; import type { TablesInsert } from "@repo/database/dbTypes"; +export const getPublishedToGroups = ( + frontmatter: FrontMatterCache | Record, +): string[] => { + const publishedToGroups = frontmatter.publishedToGroups as unknown; + if (!Array.isArray(publishedToGroups)) return []; + return publishedToGroups.filter((g): g is string => typeof g === "string"); +}; + const publishSchema = async ({ client, spaceId, @@ -229,10 +237,12 @@ export const publishNode = async ({ plugin, file, frontmatter, + groupId, }: { plugin: DiscourseGraphPlugin; file: TFile; frontmatter: FrontMatterCache; + groupId?: string; }): Promise => { const client = await getLoggedInClient(plugin); if (!client) throw new Error("Cannot get client"); @@ -243,8 +253,11 @@ export const publishNode = async ({ // Hopefully temporary workaround for sync bug await syncAllNodesAndRelations(plugin); const commonGroups = existingPublish.filter((g) => myGroups.has(g)); - // temporary single-group assumption - const myGroup = (commonGroups.length > 0 ? commonGroups : [...myGroups])[0]!; + const myGroup = + groupId ?? (commonGroups.length > 0 ? commonGroups : [...myGroups])[0]!; + if (!myGroups.has(myGroup)) { + throw new Error("You are not a member of that group"); + } return await publishNodeToGroup({ plugin, file, frontmatter, myGroup }); }; @@ -391,11 +404,13 @@ export const publishNodeToGroup = async ({ file, frontmatter, myGroup, + skipFrontmatterUpdate = false, }: { plugin: DiscourseGraphPlugin; file: TFile; frontmatter: FrontMatterCache; myGroup: string; + skipFrontmatterUpdate?: boolean; }): Promise => { const nodeId = frontmatter.nodeInstanceId as string | undefined; if (!nodeId) throw new Error("Please sync the node first"); @@ -488,11 +503,15 @@ export const publishNodeToGroup = async ({ file, attachments, }); - if (!existingPublish.includes(myGroup)) + if (!skipFrontmatterUpdate && !existingPublish.includes(myGroup)) { await plugin.app.fileManager.processFrontMatter( file, (fm: Record) => { - fm.publishedToGroups = [...existingPublish, myGroup]; + const current = getPublishedToGroups(fm); + if (!current.includes(myGroup)) { + fm.publishedToGroups = [...current, myGroup]; + } }, ); + } }; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index a4707a331..ea7e019f6 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -9,7 +9,7 @@ import { refreshAllImportedFiles } from "./importNodes"; import { VIEW_TYPE_MARKDOWN, VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; import { createCanvas } from "~/components/canvas/utils/tldraw"; import { syncAllNodesAndRelations } from "./syncDgNodesToSupabase"; -import { publishNode } from "./publishNode"; +import { openPublishGroupPicker } from "./publishGroupSelection"; import { addRelationIfRequested } from "~/components/canvas/utils/relationJsonUtils"; import type { DiscourseNode } from "~/types"; import { TldrawView } from "~/components/canvas/TldrawView"; @@ -307,23 +307,15 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { if (!frontmatter.nodeTypeId) { return false; } + if (frontmatter.importedFromRid) { + return false; + } if (!checking) { if (!frontmatter.nodeInstanceId) { new Notice("Please sync the node first"); return true; } - // TODO (in follow-up PRs): - // Maybe sync the node now if unsynced - // Ensure that the node schema is synced to the database, and shared - // sync the assets to the database - publishNode({ plugin, file, frontmatter }) - .then(() => { - new Notice("Published"); - }) - .catch((error: Error) => { - new Notice(error.message); - console.error(error); - }); + void openPublishGroupPicker({ plugin, file }); } return true; },