From 9e85c1101f788e5868bb39168b0fe7550c463490 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 12 Jun 2026 13:47:44 -0400 Subject: [PATCH 1/5] ENG-1283: Add group picker for publishing nodes in Obsidian. Let users choose which sharing groups to publish to from the discourse context sidebar and command palette, including publish-to-all support. Co-authored-by: Cursor --- .../src/components/DiscourseContextView.tsx | 81 ++---- .../src/components/PublishGroupDropdown.tsx | 249 ++++++++++++++++++ .../components/PublishGroupSuggestModal.tsx | 47 ++++ apps/obsidian/src/utils/importNodes.ts | 23 ++ .../src/utils/publishGroupSelection.ts | 180 +++++++++++++ apps/obsidian/src/utils/publishNode.ts | 22 +- apps/obsidian/src/utils/registerCommands.ts | 18 +- 7 files changed, 537 insertions(+), 83 deletions(-) create mode 100644 apps/obsidian/src/components/PublishGroupDropdown.tsx create mode 100644 apps/obsidian/src/components/PublishGroupSuggestModal.tsx create mode 100644 apps/obsidian/src/utils/publishGroupSelection.ts diff --git a/apps/obsidian/src/components/DiscourseContextView.tsx b/apps/obsidian/src/components/DiscourseContextView.tsx index 9b80c27bc..1b66f3146 100644 --- a/apps/obsidian/src/components/DiscourseContextView.tsx +++ b/apps/obsidian/src/components/DiscourseContextView.tsx @@ -19,7 +19,7 @@ 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"; @@ -45,28 +45,21 @@ 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); + const [, setMetadataVersion] = useState(0); 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]); + if (!activeFile) return; + + const ref = plugin.app.metadataCache.on("changed", (file) => { + if (file.path === activeFile.path) { + setMetadataVersion((version) => version + 1); + } + }); + + return () => { + plugin.app.metadataCache.offref(ref); + }; + }, [plugin.app.metadataCache, activeFile?.path]); const extractContentFromTitle = (format: string, title: string): string => { if (!format) return ""; @@ -99,29 +92,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 +182,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..d3a38a2ec --- /dev/null +++ b/apps/obsidian/src/components/PublishGroupDropdown.tsx @@ -0,0 +1,249 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Notice, type TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import { + getPublishedToGroups, + loadPublishGroupOptions, + publishNodeToAllGroups, + publishNodeToSelectedGroup, + 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(true); + const [isPublishing, setIsPublishing] = useState(false); + const [loadError, setLoadError] = useState(null); + + const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter; + const publishedToGroups = frontmatter + ? getPublishedToGroups(frontmatter) + : []; + const groupsWithPublishedState = withPublishedState( + groups, + publishedToGroups, + ); + + useEffect(() => { + let cancelled = false; + + const loadGroups = async () => { + setIsLoading(true); + setLoadError(null); + try { + const myGroups = await loadPublishGroupOptions(plugin); + if (!cancelled) { + setGroups(myGroups); + } + } catch (error) { + if (!cancelled) { + const message = + error instanceof Error ? error.message : String(error); + setLoadError(message); + setGroups([]); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + void loadGroups(); + + return () => { + cancelled = true; + }; + }, [plugin, file.path]); + + 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 handlePublishToGroup = useCallback( + async (groupId: string) => { + const currentFrontmatter = + plugin.app.metadataCache.getFileCache(file)?.frontmatter; + if (!currentFrontmatter) return; + + const currentPublished = getPublishedToGroups(currentFrontmatter); + if (isPublishing || currentPublished.includes(groupId)) return; + + setIsPublishing(true); + try { + await publishNodeToSelectedGroup({ + plugin, + file, + frontmatter: currentFrontmatter, + groupId, + }); + new Notice("Published successfully", 3000); + setIsOpen(false); + } 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); + } + }, + [plugin, file, isPublishing], + ); + + const unpublishedGroups = groupsWithPublishedState.filter( + (group) => !group.isPublished, + ); + + const handlePublishToAllGroups = useCallback(async () => { + if (isLoading || isPublishing || unpublishedGroups.length === 0) return; + + setIsPublishing(true); + try { + const publishedCount = await publishNodeToAllGroups({ + plugin, + file, + }); + if (publishedCount === 0) { + new Notice("Already published to all groups", 3000); + } else { + new Notice( + `Published to ${publishedCount} group${publishedCount === 1 ? "" : "s"}`, + 3000, + ); + setIsOpen(false); + } + } 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); + } + }, [plugin, file, isLoading, isPublishing, unpublishedGroups]); + + if (!frontmatter) { + return null; + } + + const publishedCount = publishedToGroups.length; + const triggerLabel = + publishedCount > 0 ? `Published (${publishedCount})` : "Publish"; + + return ( +
+ + + {isOpen && ( +
+
void handlePublishToAllGroups()} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + void handlePublishToAllGroups(); + } + }} + className={`border-b border-gray-200 px-3 py-1.5 text-xs font-medium ${ + isLoading || isPublishing || unpublishedGroups.length === 0 + ? "cursor-default text-gray-400" + : "cursor-pointer text-gray-900 hover:bg-gray-100" + }`} + title={ + unpublishedGroups.length === 0 + ? "Already published to all groups" + : `Publish to ${unpublishedGroups.length} group${unpublishedGroups.length === 1 ? "" : "s"}` + } + > + 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) => { + const isPublished = group.isPublished; + return ( + + ); + })} +
+ )} +
+ ); +}; diff --git a/apps/obsidian/src/components/PublishGroupSuggestModal.tsx b/apps/obsidian/src/components/PublishGroupSuggestModal.tsx new file mode 100644 index 000000000..bfb06f5cd --- /dev/null +++ b/apps/obsidian/src/components/PublishGroupSuggestModal.tsx @@ -0,0 +1,47 @@ +import { App, SuggestModal } from "obsidian"; +import type { PublishGroupOption } from "~/utils/publishGroupSelection"; + +type PublishGroupSuggestModalParams = { + app: App; + groups: PublishGroupOption[]; + onSelect: (group: PublishGroupOption) => void | Promise; +}; + +export class PublishGroupSuggestModal extends SuggestModal { + private groups: PublishGroupOption[]; + private onSelect: (group: PublishGroupOption) => void | Promise; + + constructor({ app, groups, onSelect }: PublishGroupSuggestModalParams) { + super(app); + this.groups = groups; + this.onSelect = onSelect; + this.setPlaceholder("Choose a group to share with"); + } + + getItemText(item: PublishGroupOption): string { + return item.isPublished ? `${item.name} (shared)` : item.name; + } + + getSuggestions(query: string): PublishGroupOption[] { + const normalizedQuery = query.toLowerCase(); + return this.groups.filter((group) => + group.name.toLowerCase().includes(normalizedQuery), + ); + } + + renderSuggestion(group: PublishGroupOption, el: HTMLElement): void { + const row = el.createDiv({ cls: "flex items-center gap-2" }); + row.createSpan({ + cls: "inline-flex w-4 shrink-0 justify-center", + text: group.isPublished ? "✓" : "", + }); + row.createSpan({ text: group.name }); + } + + onChooseSuggestion(group: PublishGroupOption): void { + if (group.isPublished) { + return; + } + void this.onSelect(group); + } +} diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 4cb8c7df3..ea820c8d6 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -37,6 +37,29 @@ export const getAvailableGroupIds = async ( return (data || []).map((g) => g.group_id); }; +export type MyGroup = { + id: string; + name: string; +}; + +export const getMyGroups = async ( + client: DGSupabaseClient, +): Promise => { + const { data, error } = await client.from("my_groups").select("id, name"); + + if (error) { + console.error("Error fetching groups:", error); + throw new Error(`Failed to fetch groups: ${error.message}`); + } + + return (data || []) + .filter( + (g): g is { id: string; name: string } => + typeof g.id === "string" && typeof g.name === "string", + ) + .map((g) => ({ id: g.id, name: g.name })); +}; + 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..a69ef8f02 --- /dev/null +++ b/apps/obsidian/src/utils/publishGroupSelection.ts @@ -0,0 +1,180 @@ +import { Notice, type FrontMatterCache, type TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import { PublishGroupSuggestModal } from "~/components/PublishGroupSuggestModal"; +import { getMyGroups, type MyGroup } from "~/utils/importNodes"; +import { getLoggedInClient } from "~/utils/supabaseContext"; +import { publishNode, publishNodeToGroup } from "~/utils/publishNode"; +import { getAvailableGroupIds } from "~/utils/importNodes"; +import { syncAllNodesAndRelations } from "~/utils/syncDgNodesToSupabase"; + +export type PublishGroupOption = MyGroup & { + isPublished: boolean; +}; + +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"); +}; + +export const loadPublishGroupOptions = async ( + plugin: DiscourseGraphPlugin, +): Promise => { + const client = await getLoggedInClient(plugin); + if (!client) { + throw new Error("Cannot connect to database"); + } + + const groups = await getMyGroups(client); + return groups.map((group) => ({ ...group, isPublished: false })); +}; + +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, + groupIds, +}: { + plugin: DiscourseGraphPlugin; + file: TFile; + groupIds?: string[]; +}): 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 targetGroupIds = + groupIds && groupIds.length > 0 ? groupIds : memberGroupIds; + const toPublish = [ + ...new Set( + targetGroupIds.filter( + (groupId) => + memberGroupIds.includes(groupId) && + !existingPublish.includes(groupId), + ), + ), + ]; + + if (toPublish.length === 0) { + return 0; + } + + if (!frontmatter.nodeInstanceId) { + throw new Error("Please sync the node first"); + } + + await syncAllNodesAndRelations(plugin); + + await Promise.all( + toPublish.map((groupId) => + publishNodeToGroup({ + plugin, + file, + frontmatter: frontmatter as FrontMatterCache, + 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 openPublishGroupPicker = async ({ + plugin, + file, + frontmatter, +}: { + plugin: DiscourseGraphPlugin; + file: TFile; + frontmatter: FrontMatterCache | Record; +}): Promise => { + let groups: PublishGroupOption[]; + try { + const myGroups = await loadPublishGroupOptions(plugin); + groups = withPublishedState(myGroups, getPublishedToGroups(frontmatter)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + new Notice(message, 5000); + return; + } + + if (groups.length === 0) { + new Notice("You are not a member of any groups", 5000); + return; + } + + new PublishGroupSuggestModal({ + app: plugin.app, + groups, + onSelect: async (group: PublishGroupOption) => { + try { + await publishNodeToSelectedGroup({ + plugin, + file, + frontmatter, + groupId: group.id, + }); + new Notice("Published successfully", 3000); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + new Notice(`Publish failed: ${message}`, 5000); + console.error("Publish failed:", error); + } + }, + }).open(); +}; diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index 78e0c86f8..efc383822 100644 --- a/apps/obsidian/src/utils/publishNode.ts +++ b/apps/obsidian/src/utils/publishNode.ts @@ -229,10 +229,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 +245,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 +396,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 +495,18 @@ 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 publishedToGroups = fm.publishedToGroups as unknown; + const current = Array.isArray(publishedToGroups) + ? publishedToGroups.filter((g): g is string => typeof g === "string") + : []; + 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 1770798a9..c54dbf14a 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"; @@ -311,23 +311,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, frontmatter }); } return true; }, From 79a6790aa0b9d99d2870b334b73f155e55af3012 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 12 Jun 2026 15:12:18 -0400 Subject: [PATCH 2/5] Fix review feedback and simplify group publish orchestration. Use sequential publish to avoid relations.json races, read fresh frontmatter in the command picker, consolidate group loading via getMyGroups, and add dark mode styles to the publish dropdown. Co-authored-by: Cursor --- .../src/components/DiscourseContextView.tsx | 18 +- .../src/components/PublishGroupDropdown.tsx | 185 ++++++++++-------- apps/obsidian/src/utils/importNodes.ts | 20 +- .../src/utils/publishGroupSelection.ts | 85 ++++---- apps/obsidian/src/utils/publishNode.ts | 13 +- apps/obsidian/src/utils/registerCommands.ts | 2 +- 6 files changed, 152 insertions(+), 171 deletions(-) diff --git a/apps/obsidian/src/components/DiscourseContextView.tsx b/apps/obsidian/src/components/DiscourseContextView.tsx index 1b66f3146..c006da3df 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"; @@ -21,7 +20,7 @@ import { import { refreshImportedFile } from "~/utils/importNodes"; 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,21 +44,6 @@ export const InfoTooltip = ({ content }: InfoTooltipProps) => ( const DiscourseContext = ({ activeFile }: DiscourseContextProps) => { const plugin = usePlugin(); const [isRefreshing, setIsRefreshing] = useState(false); - const [, setMetadataVersion] = useState(0); - - useEffect(() => { - if (!activeFile) return; - - const ref = plugin.app.metadataCache.on("changed", (file) => { - if (file.path === activeFile.path) { - setMetadataVersion((version) => version + 1); - } - }); - - return () => { - plugin.app.metadataCache.offref(ref); - }; - }, [plugin.app.metadataCache, activeFile?.path]); const extractContentFromTitle = (format: string, title: string): string => { if (!format) return ""; diff --git a/apps/obsidian/src/components/PublishGroupDropdown.tsx b/apps/obsidian/src/components/PublishGroupDropdown.tsx index d3a38a2ec..01030b3f2 100644 --- a/apps/obsidian/src/components/PublishGroupDropdown.tsx +++ b/apps/obsidian/src/components/PublishGroupDropdown.tsx @@ -3,7 +3,8 @@ import { Notice, type TFile } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { getPublishedToGroups, - loadPublishGroupOptions, + loadMyGroups, + notifyPublishError, publishNodeToAllGroups, publishNodeToSelectedGroup, withPublishedState, @@ -22,9 +23,10 @@ export const PublishGroupDropdown = ({ const containerRef = useRef(null); const [groups, setGroups] = useState([]); const [isOpen, setIsOpen] = useState(false); - const [isLoading, setIsLoading] = useState(true); + 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 = frontmatter @@ -34,23 +36,38 @@ export const PublishGroupDropdown = ({ groups, publishedToGroups, ); + const unpublishedGroups = groupsWithPublishedState.filter( + (group) => !group.isPublished, + ); + + 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 loadPublishGroupOptions(plugin); + const myGroups = await loadMyGroups(plugin); if (!cancelled) { setGroups(myGroups); } } catch (error) { if (!cancelled) { - const message = - error instanceof Error ? error.message : String(error); - setLoadError(message); + setLoadError(error instanceof Error ? error.message : String(error)); setGroups([]); } } finally { @@ -65,7 +82,7 @@ export const PublishGroupDropdown = ({ return () => { cancelled = true; }; - }, [plugin, file.path]); + }, [plugin, isOpen]); useEffect(() => { if (!isOpen) return; @@ -83,17 +100,34 @@ export const PublishGroupDropdown = ({ return () => document.removeEventListener("pointerdown", handlePointerDown); }, [isOpen]); - const handlePublishToGroup = useCallback( - async (groupId: string) => { - const currentFrontmatter = - plugin.app.metadataCache.getFileCache(file)?.frontmatter; - if (!currentFrontmatter) return; - - const currentPublished = getPublishedToGroups(currentFrontmatter); - if (isPublishing || currentPublished.includes(groupId)) return; + 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 () => { + const currentFrontmatter = + plugin.app.metadataCache.getFileCache(file)?.frontmatter; + if (!currentFrontmatter) { + throw new Error("File metadata not available"); + } + await publishNodeToSelectedGroup({ plugin, file, @@ -102,49 +136,27 @@ export const PublishGroupDropdown = ({ }); new Notice("Published successfully", 3000); setIsOpen(false); - } 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); - } + }); }, - [plugin, file, isPublishing], + [plugin, file, publishedToGroups, runPublishAction], ); - const unpublishedGroups = groupsWithPublishedState.filter( - (group) => !group.isPublished, - ); + const handlePublishToAllGroups = useCallback(() => { + if (isLoading || unpublishedGroups.length === 0) return; - const handlePublishToAllGroups = useCallback(async () => { - if (isLoading || isPublishing || unpublishedGroups.length === 0) return; - - setIsPublishing(true); - try { - const publishedCount = await publishNodeToAllGroups({ - plugin, - file, - }); + void runPublishAction(async () => { + const publishedCount = await publishNodeToAllGroups({ plugin, file }); if (publishedCount === 0) { new Notice("Already published to all groups", 3000); - } else { - new Notice( - `Published to ${publishedCount} group${publishedCount === 1 ? "" : "s"}`, - 3000, - ); - setIsOpen(false); + return; } - } 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); - } - }, [plugin, file, isLoading, isPublishing, unpublishedGroups]); + new Notice( + `Published to ${publishedCount} group${publishedCount === 1 ? "" : "s"}`, + 3000, + ); + setIsOpen(false); + }); + }, [plugin, file, isLoading, unpublishedGroups.length, runPublishAction]); if (!frontmatter) { return null; @@ -159,7 +171,7 @@ export const PublishGroupDropdown = ({ {isOpen && ( -
+
void handlePublishToAllGroups()} + onClick={() => handlePublishToAllGroups()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - void handlePublishToAllGroups(); + handlePublishToAllGroups(); } }} - className={`border-b border-gray-200 px-3 py-1.5 text-xs font-medium ${ + 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" - : "cursor-pointer text-gray-900 hover:bg-gray-100" + ? "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={ unpublishedGroups.length === 0 @@ -197,51 +209,50 @@ export const PublishGroupDropdown = ({
{isLoading && ( -
+
Loading groups...
)} {loadError && ( -
{loadError}
+
+ {loadError} +
)} {!isLoading && !loadError && groupsWithPublishedState.length === 0 && ( -
+
You are not a member of any groups.
)} {!isLoading && !loadError && - groupsWithPublishedState.map((group) => { - const isPublished = group.isPublished; - return ( - - ); - })} + groupsWithPublishedState.map((group) => ( + + ))}
)}
diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index ea820c8d6..86f19accb 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -21,22 +21,6 @@ import { import { createTemplateFile } from "./templates"; import { resolveFolderForSpaceUri } from "./importFolderMetadata"; -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 type MyGroup = { id: string; name: string; @@ -60,6 +44,10 @@ export const getMyGroups = async ( .map((g) => ({ id: g.id, name: g.name })); }; +export const getAvailableGroupIds = async ( + client: DGSupabaseClient, +): Promise => (await getMyGroups(client)).map((group) => 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 a69ef8f02..255304683 100644 --- a/apps/obsidian/src/utils/publishGroupSelection.ts +++ b/apps/obsidian/src/utils/publishGroupSelection.ts @@ -3,32 +3,35 @@ import type DiscourseGraphPlugin from "~/index"; import { PublishGroupSuggestModal } from "~/components/PublishGroupSuggestModal"; import { getMyGroups, type MyGroup } from "~/utils/importNodes"; import { getLoggedInClient } from "~/utils/supabaseContext"; -import { publishNode, publishNodeToGroup } from "~/utils/publishNode"; -import { getAvailableGroupIds } from "~/utils/importNodes"; +import { + getPublishedToGroups, + publishNode, + publishNodeToGroup, +} from "~/utils/publishNode"; import { syncAllNodesAndRelations } from "~/utils/syncDgNodesToSupabase"; export type PublishGroupOption = MyGroup & { isPublished: boolean; }; -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"); +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 loadPublishGroupOptions = async ( +export const loadMyGroups = async ( plugin: DiscourseGraphPlugin, -): Promise => { +): Promise => { const client = await getLoggedInClient(plugin); if (!client) { throw new Error("Cannot connect to database"); } - - const groups = await getMyGroups(client); - return groups.map((group) => ({ ...group, isPublished: false })); + return getMyGroups(client); }; export const withPublishedState = ( @@ -71,11 +74,9 @@ export const publishNodeToSelectedGroup = async ({ export const publishNodeToAllGroups = async ({ plugin, file, - groupIds, }: { plugin: DiscourseGraphPlugin; file: TFile; - groupIds?: string[]; }): Promise => { const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter; if (!frontmatter) { @@ -87,19 +88,11 @@ export const publishNodeToAllGroups = async ({ throw new Error("Cannot connect to database"); } - const memberGroupIds = await getAvailableGroupIds(client); + const memberGroupIds = (await getMyGroups(client)).map((group) => group.id); const existingPublish = getPublishedToGroups(frontmatter); - const targetGroupIds = - groupIds && groupIds.length > 0 ? groupIds : memberGroupIds; - const toPublish = [ - ...new Set( - targetGroupIds.filter( - (groupId) => - memberGroupIds.includes(groupId) && - !existingPublish.includes(groupId), - ), - ), - ]; + const toPublish = memberGroupIds.filter( + (groupId) => !existingPublish.includes(groupId), + ); if (toPublish.length === 0) { return 0; @@ -111,17 +104,15 @@ export const publishNodeToAllGroups = async ({ await syncAllNodesAndRelations(plugin); - await Promise.all( - toPublish.map((groupId) => - publishNodeToGroup({ - plugin, - file, - frontmatter: frontmatter as FrontMatterCache, - myGroup: groupId, - skipFrontmatterUpdate: true, - }), - ), - ); + for (const groupId of toPublish) { + await publishNodeToGroup({ + plugin, + file, + frontmatter, + myGroup: groupId, + skipFrontmatterUpdate: true, + }); + } await plugin.app.fileManager.processFrontMatter( file, @@ -137,19 +128,18 @@ export const publishNodeToAllGroups = async ({ export const openPublishGroupPicker = async ({ plugin, file, - frontmatter, }: { plugin: DiscourseGraphPlugin; file: TFile; - frontmatter: FrontMatterCache | Record; }): Promise => { let groups: PublishGroupOption[]; try { - const myGroups = await loadPublishGroupOptions(plugin); + const myGroups = await loadMyGroups(plugin); + const frontmatter = + plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? {}; groups = withPublishedState(myGroups, getPublishedToGroups(frontmatter)); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - new Notice(message, 5000); + new Notice(getErrorMessage(error), 5000); return; } @@ -163,6 +153,11 @@ export const openPublishGroupPicker = async ({ groups, onSelect: async (group: PublishGroupOption) => { try { + const frontmatter = + plugin.app.metadataCache.getFileCache(file)?.frontmatter; + if (!frontmatter) { + throw new Error("File metadata not available"); + } await publishNodeToSelectedGroup({ plugin, file, @@ -171,9 +166,7 @@ export const openPublishGroupPicker = async ({ }); new Notice("Published successfully", 3000); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - new Notice(`Publish failed: ${message}`, 5000); - console.error("Publish failed:", error); + notifyPublishError(error); } }, }).open(); diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index efc383822..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, @@ -499,10 +507,7 @@ export const publishNodeToGroup = async ({ await plugin.app.fileManager.processFrontMatter( file, (fm: Record) => { - const publishedToGroups = fm.publishedToGroups as unknown; - const current = Array.isArray(publishedToGroups) - ? publishedToGroups.filter((g): g is string => typeof g === "string") - : []; + 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 c54dbf14a..2bc1333ff 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -319,7 +319,7 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { new Notice("Please sync the node first"); return true; } - void openPublishGroupPicker({ plugin, file, frontmatter }); + void openPublishGroupPicker({ plugin, file }); } return true; }, From 7855d345a991d3c7b78dd9ce1154f497afa22f2f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 12 Jun 2026 15:14:15 -0400 Subject: [PATCH 3/5] Fix react-hooks lint in PublishGroupDropdown. Memoize publishedToGroups so useCallback dependencies stay stable across renders. Co-authored-by: Cursor --- apps/obsidian/src/components/PublishGroupDropdown.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/obsidian/src/components/PublishGroupDropdown.tsx b/apps/obsidian/src/components/PublishGroupDropdown.tsx index 01030b3f2..74a83067d 100644 --- a/apps/obsidian/src/components/PublishGroupDropdown.tsx +++ b/apps/obsidian/src/components/PublishGroupDropdown.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Notice, type TFile } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { @@ -29,9 +29,10 @@ export const PublishGroupDropdown = ({ const [, setMetadataVersion] = useState(0); const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter; - const publishedToGroups = frontmatter - ? getPublishedToGroups(frontmatter) - : []; + const publishedToGroups = useMemo( + () => (frontmatter ? getPublishedToGroups(frontmatter) : []), + [frontmatter], + ); const groupsWithPublishedState = withPublishedState( groups, publishedToGroups, From e272073f80bbc1a97dc2316cef1b6d61015ff334 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 12 Jun 2026 15:28:17 -0400 Subject: [PATCH 4/5] Use group_membership for group lookups consistently. Restore getAvailableGroupIds to query group_membership like main, and derive getMyGroups from the same table with an embedded my_groups join for display names. Co-authored-by: Cursor --- apps/obsidian/src/utils/importNodes.ts | 39 ++++++++++++++----- .../src/utils/publishGroupSelection.ts | 8 +++- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 86f19accb..1502a3417 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -26,28 +26,49 @@ export type MyGroup = { 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 { data, error } = await client.from("my_groups").select("id, name"); + 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 || []) + return (data ?? []) .filter( - (g): g is { id: string; name: string } => - typeof g.id === "string" && typeof g.name === "string", + (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((g) => ({ id: g.id, name: g.name })); + .map((row) => ({ + id: row.group_id, + name: row.my_groups.name ?? row.group_id, + })); }; -export const getAvailableGroupIds = async ( - client: DGSupabaseClient, -): Promise => (await getMyGroups(client)).map((group) => 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 255304683..c67894572 100644 --- a/apps/obsidian/src/utils/publishGroupSelection.ts +++ b/apps/obsidian/src/utils/publishGroupSelection.ts @@ -1,7 +1,11 @@ import { Notice, type FrontMatterCache, type TFile } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { PublishGroupSuggestModal } from "~/components/PublishGroupSuggestModal"; -import { getMyGroups, type MyGroup } from "~/utils/importNodes"; +import { + getAvailableGroupIds, + getMyGroups, + type MyGroup, +} from "~/utils/importNodes"; import { getLoggedInClient } from "~/utils/supabaseContext"; import { getPublishedToGroups, @@ -88,7 +92,7 @@ export const publishNodeToAllGroups = async ({ throw new Error("Cannot connect to database"); } - const memberGroupIds = (await getMyGroups(client)).map((group) => group.id); + const memberGroupIds = await getAvailableGroupIds(client); const existingPublish = getPublishedToGroups(frontmatter); const toPublish = memberGroupIds.filter( (groupId) => !existingPublish.includes(groupId), From fb8a8510709fc800df53f19a8b1dc713c0959984 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 12 Jun 2026 15:47:36 -0400 Subject: [PATCH 5/5] fixes to sync between the suggest modal and the dropdown --- .../src/components/PublishGroupDropdown.tsx | 44 ++------- .../components/PublishGroupSuggestModal.tsx | 53 ++++++---- .../src/utils/publishGroupSelection.ts | 96 +++++++++++++++++-- 3 files changed, 129 insertions(+), 64 deletions(-) diff --git a/apps/obsidian/src/components/PublishGroupDropdown.tsx b/apps/obsidian/src/components/PublishGroupDropdown.tsx index 74a83067d..fe7655908 100644 --- a/apps/obsidian/src/components/PublishGroupDropdown.tsx +++ b/apps/obsidian/src/components/PublishGroupDropdown.tsx @@ -1,12 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Notice, type TFile } from "obsidian"; +import { type TFile } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { getPublishedToGroups, + getPublishToAllTitle, + getUnpublishedGroups, loadMyGroups, notifyPublishError, - publishNodeToAllGroups, - publishNodeToSelectedGroup, + publishToAllGroupsWithNotice, + publishToSelectedGroupWithNotice, withPublishedState, } from "~/utils/publishGroupSelection"; import type { MyGroup } from "~/utils/importNodes"; @@ -37,9 +39,7 @@ export const PublishGroupDropdown = ({ groups, publishedToGroups, ); - const unpublishedGroups = groupsWithPublishedState.filter( - (group) => !group.isPublished, - ); + const unpublishedGroups = getUnpublishedGroups(groupsWithPublishedState); useEffect(() => { const ref = plugin.app.metadataCache.on("changed", (changedFile) => { @@ -123,19 +123,7 @@ export const PublishGroupDropdown = ({ if (publishedToGroups.includes(groupId)) return; void runPublishAction(async () => { - const currentFrontmatter = - plugin.app.metadataCache.getFileCache(file)?.frontmatter; - if (!currentFrontmatter) { - throw new Error("File metadata not available"); - } - - await publishNodeToSelectedGroup({ - plugin, - file, - frontmatter: currentFrontmatter, - groupId, - }); - new Notice("Published successfully", 3000); + await publishToSelectedGroupWithNotice({ plugin, file, groupId }); setIsOpen(false); }); }, @@ -146,15 +134,7 @@ export const PublishGroupDropdown = ({ if (isLoading || unpublishedGroups.length === 0) return; void runPublishAction(async () => { - 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, - ); + await publishToAllGroupsWithNotice({ plugin, file }); setIsOpen(false); }); }, [plugin, file, isLoading, unpublishedGroups.length, runPublishAction]); @@ -175,7 +155,7 @@ export const PublishGroupDropdown = ({ disabled={isLoading && isOpen} className={`rounded border px-2 py-1 text-xs ${ publishedCount > 0 - ? "border-green-600 bg-green-200 text-green-800 dark:bg-green-900/60 dark:text-green-300" + ? "border-green-600 bg-green-200 text-green-800 dark:bg-green-900/60 dark:text-green-100" : "border border-gray-400 bg-gray-100 font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700" }`} title="Publish to a group" @@ -200,11 +180,7 @@ export const PublishGroupDropdown = ({ ? "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={ - unpublishedGroups.length === 0 - ? "Already published to all groups" - : `Publish to ${unpublishedGroups.length} group${unpublishedGroups.length === 1 ? "" : "s"}` - } + title={getPublishToAllTitle(unpublishedGroups.length)} > Publish to all groups
diff --git a/apps/obsidian/src/components/PublishGroupSuggestModal.tsx b/apps/obsidian/src/components/PublishGroupSuggestModal.tsx index bfb06f5cd..0f1f3a7c9 100644 --- a/apps/obsidian/src/components/PublishGroupSuggestModal.tsx +++ b/apps/obsidian/src/components/PublishGroupSuggestModal.tsx @@ -1,47 +1,60 @@ import { App, SuggestModal } from "obsidian"; -import type { PublishGroupOption } from "~/utils/publishGroupSelection"; +import type { PublishGroupSuggestItem } from "~/utils/publishGroupSelection"; type PublishGroupSuggestModalParams = { app: App; - groups: PublishGroupOption[]; - onSelect: (group: PublishGroupOption) => void | Promise; + items: PublishGroupSuggestItem[]; + onChoose: (item: PublishGroupSuggestItem) => void | Promise; }; -export class PublishGroupSuggestModal extends SuggestModal { - private groups: PublishGroupOption[]; - private onSelect: (group: PublishGroupOption) => void | Promise; +export class PublishGroupSuggestModal extends SuggestModal { + private items: PublishGroupSuggestItem[]; + private onChoose: (item: PublishGroupSuggestItem) => void | Promise; - constructor({ app, groups, onSelect }: PublishGroupSuggestModalParams) { + constructor({ app, items, onChoose }: PublishGroupSuggestModalParams) { super(app); - this.groups = groups; - this.onSelect = onSelect; + this.items = items; + this.onChoose = onChoose; this.setPlaceholder("Choose a group to share with"); } - getItemText(item: PublishGroupOption): string { + getItemText(item: PublishGroupSuggestItem): string { + if (item.isPublishToAll) { + return item.name; + } return item.isPublished ? `${item.name} (shared)` : item.name; } - getSuggestions(query: string): PublishGroupOption[] { + getSuggestions(query: string): PublishGroupSuggestItem[] { const normalizedQuery = query.toLowerCase(); - return this.groups.filter((group) => - group.name.toLowerCase().includes(normalizedQuery), + return this.items.filter((item) => + item.name.toLowerCase().includes(normalizedQuery), ); } - renderSuggestion(group: PublishGroupOption, el: HTMLElement): void { - const row = el.createDiv({ cls: "flex items-center gap-2" }); + 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: group.isPublished ? "✓" : "", + text: item.isPublished ? "✓" : "", }); - row.createSpan({ text: group.name }); + row.createSpan({ text: item.name }); } - onChooseSuggestion(group: PublishGroupOption): void { - if (group.isPublished) { + onChooseSuggestion(item: PublishGroupSuggestItem): void { + if (item.isPublished) { return; } - void this.onSelect(group); + void this.onChoose(item); } } diff --git a/apps/obsidian/src/utils/publishGroupSelection.ts b/apps/obsidian/src/utils/publishGroupSelection.ts index c67894572..09adcba56 100644 --- a/apps/obsidian/src/utils/publishGroupSelection.ts +++ b/apps/obsidian/src/utils/publishGroupSelection.ts @@ -18,6 +18,12 @@ 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 => @@ -28,6 +34,35 @@ export const notifyPublishError = (error: unknown): void => { 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 => { @@ -129,6 +164,47 @@ export const publishNodeToAllGroups = async ({ 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, @@ -154,21 +230,21 @@ export const openPublishGroupPicker = async ({ new PublishGroupSuggestModal({ app: plugin.app, - groups, - onSelect: async (group: PublishGroupOption) => { + items: buildPublishGroupPickerItems(groups), + onChoose: async (item: PublishGroupSuggestItem) => { try { - const frontmatter = - plugin.app.metadataCache.getFileCache(file)?.frontmatter; - if (!frontmatter) { - throw new Error("File metadata not available"); + if (isPublishToAllItem(item)) { + await publishToAllGroupsWithNotice({ plugin, file }); + return; + } + if (item.isPublished) { + return; } - await publishNodeToSelectedGroup({ + await publishToSelectedGroupWithNotice({ plugin, file, - frontmatter, - groupId: group.id, + groupId: item.id, }); - new Notice("Published successfully", 3000); } catch (error) { notifyPublishError(error); }