Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
<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