Skip to content
Merged
52 changes: 5 additions & 47 deletions apps/obsidian/src/utils/importNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,53 +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<string[]> => {
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<MyGroup[]> => {
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,
}));
};
// Group membership helpers were promoted to @repo/database/lib/groups so Roam
// can share them (apps can't cross-import). Re-exported here for existing
// Obsidian importers.
export type { MyGroup } from "@repo/database/lib/groups";
export { getAvailableGroupIds, getMyGroups } from "@repo/database/lib/groups";

type PublishedNode = {
source_local_id: string;
Expand Down
180 changes: 180 additions & 0 deletions apps/roam/src/components/Export.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ 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 {
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);
Expand Down Expand Up @@ -213,6 +220,28 @@ const ExportDialog: ExportDialogComponent = ({

const [canSendToGitHub, setCanSendToGitHub] = useState(false);

const syncEnabled = useMemo(() => isSyncEnabled(), []);
const [myGroups, setMyGroups] = useState<MyGroup[]>([]);
const [groupsLoading, setGroupsLoading] = useState(false);
const [groupsLoaded, setGroupsLoaded] = useState(false);
const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]);
const [groupsError, setGroupsError] = useState("");
const [publishError, setPublishError] = useState("");

const publishableNodes = useMemo(
() =>
syncEnabled
? results
.map((r) => {
const node = findDiscourseNode({ uid: r.uid });
return node ? { uid: r.uid, type: node.type } : null;
})
.filter((n): n is PublishNode => n !== null)
: [],
[results, syncEnabled],
);
const nonDiscourseCount = results.length - publishableNodes.length;

const writeFileToRepo = async ({
filename,
content,
Expand Down Expand Up @@ -764,6 +793,92 @@ 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);
setMyGroups(groups);
} catch (e) {
setGroupsError((e as Error).message || "Failed to load groups.");
} finally {
setGroupsLoading(false);
setGroupsLoaded(true);
}
})();
}, [syncEnabled, isOpen, selectedTabId, groupsLoaded, groupsLoading]);

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 messages = [
`Published ${publishedNodeUids.length} node${
publishedNodeUids.length === 1 ? "" : "s"
} to ${okGroupIds.length} group${okGroupIds.length === 1 ? "" : "s"}.`,
];
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 ? "warning" : "success",
id: "query-builder-publish-success",
});
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 = (
<>
<div className={Classes.DIALOG_BODY}>
Expand Down Expand Up @@ -1042,6 +1157,68 @@ const ExportDialog: ExportDialogComponent = ({
</>
);

const PublishPanel = (
<>
<div className={Classes.DIALOG_BODY}>
{groupsLoading ? (
<div className="my-2.5">Loading groups…</div>
Comment thread
sid597 marked this conversation as resolved.
Outdated
) : groupsError ? (
<div className="my-2.5">{groupsError}</div>
) : myGroups.length === 0 ? (
<div className="my-2.5">
You are not a member of any sharing group.
</div>
) : (
<>
<Label>Publish to group(s)</Label>
{myGroups.map((group) => (
<Checkbox
key={group.id}
checked={selectedGroupIds.includes(group.id)}
label={group.name}
onChange={(e) => {
const { checked } = e.target as HTMLInputElement;
setSelectedGroupIds((prev) =>
checked
? [...prev, group.id]
: prev.filter((id) => id !== group.id),
);
}}
/>
))}
<div className="mt-2.5">
{`Publishing ${publishableNodes.length} discourse node${
publishableNodes.length === 1 ? "" : "s"
}`}
{nonDiscourseCount > 0 &&
` (${nonDiscourseCount} non-discourse result${
nonDiscourseCount === 1 ? "" : "s"
} will be skipped)`}
</div>
</>
)}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<span style={{ color: "darkred" }}>{publishError}</span>
Comment thread
sid597 marked this conversation as resolved.
Outdated
<Button text={"Cancel"} intent={Intent.NONE} onClick={onClose} />
<Button
text={"Publish"}
intent={Intent.PRIMARY}
onClick={() => void handlePublish()}
loading={loading}
disabled={
loading ||
selectedGroupIds.length === 0 ||
publishableNodes.length === 0
}
style={{ minWidth: 64 }}
/>
</div>
</div>
</>
);

return (
<>
<Dialog
Expand All @@ -1067,6 +1244,9 @@ const ExportDialog: ExportDialogComponent = ({
>
<Tab id="sendto" title="Send To" panel={SendToPanel} />
<Tab id="export" title="Export" panel={ExportPanel} />
{syncEnabled && (
<Tab id="publish" title="Publish" panel={PublishPanel} />
)}
</Tabs>
</Dialog>
<ExportProgress id={exportId} />
Expand Down
114 changes: 114 additions & 0 deletions apps/roam/src/utils/publishNodesToGroups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { DGSupabaseClient } from "@repo/database/lib/client";

export type PublishNode = {
uid: string;
type: string;
};

type PublishNodesResult = {
publishedNodeUids: string[];
skippedUnsyncedUids: string[];
okGroupIds: string[];
failedGroupIds: string[];
};

// 23505 = unique_violation: the grant already exists, which counts as success.
const isIgnorableUpsertError = (error: { code?: string } | null): boolean =>
!error || error.code === "23505";

const onlyStrings = (values: (string | null)[]): string[] =>
values.filter((value): value is string => typeof value === "string");

// Grants a group access to already-synced discourse nodes by mirroring the
// Obsidian publish-to-group access model (SpaceAccess + ResourceAccess),
// without its file/frontmatter/relation/asset coupling.
//
// ResourceAccess has no foreign key on source_local_id, so granting access to a
// node that has not synced yet would create an orphaned row. We therefore only
// publish nodes confirmed present as instance concepts in this space, and
// report the rest as not-yet-synced (they self-heal on the next sync).
export const publishNodesToGroups = async ({
client,
spaceId,
groupIds,
nodes,
}: {
client: DGSupabaseClient;
spaceId: number;
groupIds: string[];
nodes: PublishNode[];
}): Promise<PublishNodesResult> => {
const result: PublishNodesResult = {
publishedNodeUids: [],
skippedUnsyncedUids: [],
okGroupIds: [],
failedGroupIds: [],
};
if (nodes.length === 0 || groupIds.length === 0) return result;

const uids = [...new Set(nodes.map((node) => node.uid))];

const syncedRes = await client
.from("my_concepts")
.select("source_local_id")
.eq("space_id", spaceId)
.eq("is_schema", false)
.in("source_local_id", uids);
if (syncedRes.error) throw syncedRes.error;
const syncedUids = new Set(
onlyStrings((syncedRes.data ?? []).map((row) => row.source_local_id)),
);

result.skippedUnsyncedUids = uids.filter((uid) => !syncedUids.has(uid));
const syncedNodeUids = uids.filter((uid) => syncedUids.has(uid));
if (syncedNodeUids.length === 0) return result;

// Required dependency: the node-type schema concept, when it is synced too.
const types = [
...new Set(
nodes.filter((node) => syncedUids.has(node.uid)).map((node) => node.type),
),
];
const schemaRes = await client
.from("my_concepts")
.select("source_local_id")
.eq("space_id", spaceId)
.eq("is_schema", true)
.in("source_local_id", types);
const syncedSchemaIds = onlyStrings(
(schemaRes.data ?? []).map((row) => row.source_local_id),
);
Comment thread
sid597 marked this conversation as resolved.
Comment thread
sid597 marked this conversation as resolved.

const resourceIds = [...syncedNodeUids, ...syncedSchemaIds];

for (const groupId of groupIds) {
const spaceAccessRes = await client
.from("SpaceAccess")
.upsert(
{ account_uid: groupId, space_id: spaceId, permissions: "partial" },
{ ignoreDuplicates: true },
);
if (!isIgnorableUpsertError(spaceAccessRes.error)) {
result.failedGroupIds.push(groupId);
continue;
}
Comment thread
sid597 marked this conversation as resolved.

const grantRes = await client.from("ResourceAccess").upsert(
resourceIds.map((sourceLocalId) => ({
account_uid: groupId,
source_local_id: sourceLocalId,
space_id: spaceId,
})),
{ ignoreDuplicates: true },
);
if (!isIgnorableUpsertError(grantRes.error)) {
result.failedGroupIds.push(groupId);
continue;
}

result.okGroupIds.push(groupId);
}

result.publishedNodeUids = result.okGroupIds.length > 0 ? syncedNodeUids : [];
Comment thread
sid597 marked this conversation as resolved.
return result;
};
Loading