diff --git a/apps/roam/scripts/compile.ts b/apps/roam/scripts/compile.ts index aa57b71fd..2054084bd 100644 --- a/apps/roam/scripts/compile.ts +++ b/apps/roam/scripts/compile.ts @@ -133,6 +133,9 @@ export const compile = ({ minify: process.env.NODE_ENV === "production", entryNames: out, external: externalModules.map(([e]) => e).concat(["crypto"]), + define: { + "process.env.NODE_ENV": `"${process.env.NODE_ENV}"`, + }, plugins: [ importAsGlobals( Object.fromEntries( diff --git a/apps/roam/src/components/Export.tsx b/apps/roam/src/components/Export.tsx index 6218cef42..d22e57bfb 100644 --- a/apps/roam/src/components/Export.tsx +++ b/apps/roam/src/components/Export.tsx @@ -82,23 +82,26 @@ const ExportProgress = ({ id }: { id: string }) => { ); }; +const EXPORT_DESTINATIONS = [ + { id: "local", label: "Download Locally", active: true }, + { id: "app", label: "Store in Roam", active: false }, + { id: "github", label: "Send to GitHub", active: true }, +]; + export type ExportDialogProps = { results?: Result[]; title?: string; columns?: Column[]; isExportDiscourseGraph?: boolean; initialPanel?: "sendTo" | "export"; + initialExportDestination?: (typeof EXPORT_DESTINATIONS)[number]["id"]; + onClose?: () => void; }; type ExportDialogComponent = ( props: RoamOverlayProps, ) => JSX.Element; -const EXPORT_DESTINATIONS = [ - { id: "local", label: "Download Locally", active: true }, - { id: "app", label: "Store in Roam", active: false }, - { id: "github", label: "Send to GitHub", active: true }, -]; const SEND_TO_DESTINATIONS = ["page", "graph"]; const exportDestinationById = Object.fromEntries( @@ -113,9 +116,10 @@ const ExportDialog: ExportDialogComponent = ({ title = "Share Data", isExportDiscourseGraph = false, initialPanel, + initialExportDestination, }) => { const [selectedRepo, setSelectedRepo] = useState( - getSetting("selected-repo", ""), + getSetting("selected-repo"), ); const exportId = useMemo(() => nanoid(), []); useEffect(() => { @@ -143,7 +147,11 @@ const ExportDialog: ExportDialogComponent = ({ exportTypes[0].name, ); const [activeExportDestination, setActiveExportDestination] = - useState(EXPORT_DESTINATIONS[0].id); + useState( + initialExportDestination + ? exportDestinationById[initialExportDestination].id + : EXPORT_DESTINATIONS[0].id, + ); const firstColumnKey = columns?.[0]?.key || "text"; const currentPageUid = getCurrentPageUid(); @@ -163,7 +171,7 @@ const ExportDialog: ExportDialogComponent = ({ }, [initialPanel]); const [includeDiscourseContext, setIncludeDiscourseContext] = useState(false); const [gitHubAccessToken, setGitHubAccessToken] = useState( - getSetting("oauth-github", null), + getSetting("oauth-github"), ); const [canSendToGitHub, setCanSendToGitHub] = useState(false); @@ -177,9 +185,15 @@ const ExportDialog: ExportDialogComponent = ({ content: string; setError: (error: string) => void; }): Promise<{ status: number }> => { - const base64Content = btoa(content); + const gitHubAccessToken = getSetting("github-oauth"); + const selectedRepo = getSetting("github-repo"); + + const encoder = new TextEncoder(); + const uint8Array = encoder.encode(content); + const base64Content = btoa(String.fromCharCode(...uint8Array)); try { + // https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#create-or-update-file-contents const response = await apiPut({ domain: "https://api.github.com", path: `repos/${selectedRepo}/contents/${filename}`, @@ -192,7 +206,6 @@ const ExportDialog: ExportDialogComponent = ({ }, }); if (response.status === 401) { - setGitHubAccessToken(null); setError("Authentication failed. Please log in again."); setSetting("oauth-github", ""); return { status: 401 }; @@ -209,6 +222,74 @@ const ExportDialog: ExportDialogComponent = ({ } }; + const writeFileToIssue = async ({ + title, + body, + setError, + pageUid, + }: { + title: string; + body: string; + setError: (error: string) => void; + pageUid: string; + }): Promise<{ status: number }> => { + const gitHubAccessToken = getSetting("github-oauth"); + const selectedRepo = getSetting("github-repo"); + try { + // https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#create-an-issue + const response = await apiPost({ + domain: "https://api.github.com", + path: `repos/${selectedRepo}/issues`, + headers: { + Authorization: `token ${gitHubAccessToken}`, + }, + data: { + title, + body, + // milestone, + // labels, + // assignees + }, + }); + if (response.status === 401) { + setError("Authentication failed. Please log in again."); + setSetting("github-oauth", ""); + return { status: 401 }; + } + + if (response.status === 201) { + const props = getBlockProps(pageUid); + const newProps = { + ...props, + ["github-sync"]: { + issue: { + id: response.id, + number: response.number, + html_url: response.html_url, + state: response.state, + labels: response.labels, + createdAt: response.created_at, + updatedAt: response.updated_at, + repo: selectedRepo, + }, + }, + }; + window.roamAlphaAPI.updateBlock({ + block: { + uid: pageUid, + props: newProps, + }, + }); + } + + return { status: response.status }; + } catch (error) { + const e = error as Error; + setError("Failed to create issue"); + return { status: 500 }; + } + }; + const handleSetSelectedPage = (title: string) => { setSelectedPageTitle(title); setSelectedPageUid(getPageUidByPageTitle(title)); @@ -521,15 +602,12 @@ const ExportDialog: ExportDialogComponent = ({ onItemSelect={(et) => setActiveExportDestination(et)} /> - + {activeExportDestination === "github" && ( + + )} @@ -620,17 +698,40 @@ const ExportDialog: ExportDialogComponent = ({ if (activeExportDestination === "github") { const { title, content } = files[0]; + const githubDestination = + getSetting("github-destination"); try { - const { status } = await writeFileToRepo({ - filename: title, - content, - setError, - }); + let status; + if (githubDestination === "File") { + status = ( + await writeFileToRepo({ + filename: title, + content, + setError, + }) + ).status; + } + if (githubDestination === "Issue") { + const pageUid = + typeof results === "function" ? "" : results[0].uid; // TODO handle multiple results + if (!pageUid) { + setError("No page UID found."); + return; + } + status = ( + await writeFileToIssue({ + title: title.replace(/\.[^/.]+$/, ""), // remove extension + body: content, + setError, + pageUid, + }) + ).status; + } if (status === 201) { // TODO: remove toast by prolonging ExportProgress renderToast({ id: "export-success", - content: "Upload Success", + content: `Upload Success to ${githubDestination}`, intent: "success", }); onClose(); diff --git a/apps/roam/src/components/ExportGithub.tsx b/apps/roam/src/components/ExportGithub.tsx index b72b0181b..dff1d2b36 100644 --- a/apps/roam/src/components/ExportGithub.tsx +++ b/apps/roam/src/components/ExportGithub.tsx @@ -1,4 +1,4 @@ -import { Button } from "@blueprintjs/core"; +import { Button, Label } from "@blueprintjs/core"; import nanoid from "nanoid"; import React, { useCallback, @@ -12,9 +12,20 @@ import apiGet from "roamjs-components/util/apiGet"; import apiPost from "roamjs-components/util/apiPost"; import { getNodeEnv } from "roamjs-components/util/env"; import getExtensionApi from "roamjs-components/util/extensionApiContext"; -import { setSetting } from "~/utils/extensionSettings"; +import { getSetting, setSetting } from "~/utils/extensionSettings"; +import { + GH_APP_ID_DEV, + GH_APP_ID_PROD, + GH_APP_URL_DEV, + GH_CLIENT_ID_DEV, + GH_CLIENT_ID_PROD, + GH_APP_URL_PROD, + API_URL_DEV, + API_URL_PROD, +} from "~/constants"; +import sendErrorEmail from "~/utils/sendErrorEmail"; -type UserReposResponse = { +export type UserReposResponse = { data: [ { name: string; @@ -23,29 +34,62 @@ type UserReposResponse = { ]; status: number; }; -type UserRepos = UserReposResponse["data"]; -const initialRepos: UserRepos = [{ name: "", full_name: "" }]; +export type UserRepos = UserReposResponse["data"]; +export const initialRepos: UserRepos = [{ name: "", full_name: "" }]; +export type GitHubDestination = (typeof GITHUB_DESTINATIONS)[number]; +const GITHUB_DESTINATIONS = ["Issue", "File"] as const; -const WINDOW_WIDTH = 600; -const WINDOW_HEIGHT = 525; -const WINDOW_LEFT = window.screenX + (window.innerWidth - WINDOW_WIDTH) / 2; -const WINDOW_TOP = window.screenY + (window.innerHeight - WINDOW_HEIGHT) / 2; +export const WINDOW_WIDTH = 600; +export const WINDOW_HEIGHT = 525; +export const WINDOW_LEFT = + window.screenX + (window.innerWidth - WINDOW_WIDTH) / 2; +export const WINDOW_TOP = + window.screenY + (window.innerHeight - WINDOW_HEIGHT) / 2; + +const isDev = getNodeEnv() === "development"; +// const isDev = false; +const APP_ID = isDev ? GH_APP_ID_DEV : GH_APP_ID_PROD; +const CLIENT_ID = isDev ? GH_CLIENT_ID_DEV : GH_CLIENT_ID_PROD; +const API_URL = isDev ? API_URL_DEV : API_URL_PROD; +const APP_URL = isDev ? GH_APP_URL_DEV : GH_APP_URL_PROD; + +export const fetchInstallationStatus = async (token: string) => { + try { + // https://docs.github.com/en/rest/apps/installations?apiVersion=2022-11-28#list-app-installations-accessible-to-the-user-access-token + const res = await apiGet<{ installations: { app_id: number }[] }>({ + domain: "https://api.github.com", + path: "user/installations", + headers: { + Authorization: `token ${token}`, + }, + }); + const installations = res.installations; + console.log("installations", installations); + + const isAppInstalled = installations.some( + (installation) => installation.app_id === APP_ID, + ); + return isAppInstalled; + } catch (error) { + const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "Export GitHub - Installation Status Check Failed", + context: { + token: token ? "present" : "missing", + }, + }).catch(() => {}); + + return false; + } +}; export const ExportGithub = ({ - isVisible, - selectedRepo, - setSelectedRepo, setError, - gitHubAccessToken, - setGitHubAccessToken, setCanSendToGitHub, }: { - isVisible: boolean; - selectedRepo: string; - setSelectedRepo: (selectedRepo: string) => void; setError: (error: string) => void; - gitHubAccessToken: string | null; - setGitHubAccessToken: (gitHubAccessToken: string | null) => void; setCanSendToGitHub: (canSendToGitHub: boolean) => void; }) => { const authWindow = useRef(null); @@ -54,13 +98,29 @@ export const ExportGithub = ({ const [clickedInstall, setClickedInstall] = useState(false); const [repos, setRepos] = useState(initialRepos); const [state, setState] = useState(""); + const [gitHubAccessToken, _setGitHubAccessToken] = useState( + getSetting("github-oauth"), + ); + const [githubDestination, _setGithubDestination] = + useState(getSetting("github-destination") || "File"); + const [selectedRepo, _setSelectedRepo] = useState( + getSetting("github-repo"), + ); const showGitHubLogin = isGitHubAppInstalled && !gitHubAccessToken; - const repoSelectEnabled = isGitHubAppInstalled && gitHubAccessToken; + const repoAndDestinationSelectEnabled = + isGitHubAppInstalled && gitHubAccessToken; - const isDev = useMemo(() => getNodeEnv() === "development", []); - const setRepo = (repo: string) => { - setSelectedRepo(repo); - setSetting("selected-repo", repo); + const setGitHubAccessToken = (token: string) => { + setSetting("github-oauth", token); + _setGitHubAccessToken(token); + }; + const setGithubDestination = (destination: GitHubDestination) => { + setSetting("github-destination", destination); + _setGithubDestination(destination); + }; + const setSelectedRepo = (repo: string) => { + setSetting("github-repo", repo); + _setSelectedRepo(repo); }; const handleReceivedAccessToken = (token: string) => { @@ -72,30 +132,23 @@ export const ExportGithub = ({ const fetchAndSetInstallation = useCallback(async (token: string) => { try { - const res = await apiGet<{ installations: { app_id: number }[] }>({ - domain: "https://api.github.com", - path: "user/installations", - headers: { - Authorization: `token ${token}`, - }, - }); - - const installations = res.installations; - const APP_ID = isDev ? 882491 : 312167; // TODO - pull from process.env.GITHUB_APP_ID - const isAppInstalled = installations.some( - (installation) => installation.app_id === APP_ID, - ); - + const isAppInstalled = await fetchInstallationStatus(gitHubAccessToken); setIsGitHubAppInstalled(isAppInstalled); - return isAppInstalled; } catch (error) { const e = error as Error; + await sendErrorEmail({ + error: e, + type: "Export GitHub - Installation Status Check Failed", + context: { + gitHubAccessToken: gitHubAccessToken ? "present" : "missing", + }, + }).catch(() => {}); + if (e.message === "Bad credentials") { - setGitHubAccessToken(null); + setGitHubAccessToken(""); setSetting("oauth-github", ""); } - return false; } }, []); @@ -115,16 +168,11 @@ export const ExportGithub = ({ } }; - if (isVisible) { - window.addEventListener("message", handleGitHubAuthMessage); - } - + window.addEventListener("message", handleGitHubAuthMessage); return () => { - if (isVisible) { - window.removeEventListener("message", handleGitHubAuthMessage); - } + window.removeEventListener("message", handleGitHubAuthMessage); }; - }, [isVisible]); + }, []); // check for installation useEffect(() => { @@ -136,6 +184,7 @@ export const ExportGithub = ({ if (!gitHubAccessToken || !isGitHubAppInstalled) return; const fetchAndSetRepos = async () => { try { + // https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user const res = await apiGet({ domain: "https://api.github.com", path: "user/repos?per_page=100&type=owner", @@ -146,6 +195,17 @@ export const ExportGithub = ({ setError(""); setRepos(res.data); } catch (error) { + const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "Export GitHub - Repository Fetch Failed", + context: { + gitHubAccessToken: gitHubAccessToken ? "present" : "missing", + isGitHubAppInstalled, + }, + }).catch(() => {}); + setError("Failed to fetch repositories"); } }; @@ -159,91 +219,100 @@ export const ExportGithub = ({ } }, [gitHubAccessToken, isGitHubAppInstalled, selectedRepo]); - if (!isVisible) return null; return (
-
- {!isGitHubAppInstalled && ( -
+ {(!isGitHubAppInstalled || clickedInstall) && ( +
+ {!isGitHubAppInstalled && ( +
+ )} {showGitHubLogin && (
); diff --git a/apps/roam/src/components/GitHubSync.tsx b/apps/roam/src/components/GitHubSync.tsx new file mode 100644 index 000000000..8e7c4cd07 --- /dev/null +++ b/apps/roam/src/components/GitHubSync.tsx @@ -0,0 +1,1093 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes"; +import matchDiscourseNode from "~/utils/matchDiscourseNode"; +import { OnloadArgs, RoamBasicNode } from "roamjs-components/types"; +import { Button, Card, Classes, Dialog, Tag } from "@blueprintjs/core"; +import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import createBlock from "roamjs-components/writes/createBlock"; +import runQuery from "~/utils/runQuery"; +import { render as renderToast } from "roamjs-components/components/Toast"; +import { render as exportRender } from "~/components/Export"; +import getBlockProps from "~/utils/getBlockProps"; +import apiGet from "roamjs-components/util/apiGet"; +import { handleTitleAdditions } from "~/utils/handleTitleAdditions"; +import getCurrentUserDisplayName from "roamjs-components/queries/getCurrentUserDisplayName"; +import getFirstChildUidByBlockUid from "roamjs-components/queries/getFirstChildUidByBlockUid"; +import getUids from "roamjs-components/dom/getUids"; +import createBlockObserver from "roamjs-components/dom/createBlockObserver"; +import ReactDOM from "react-dom"; +import getUidsFromButton from "roamjs-components/dom/getUidsFromButton"; +import getPageUidByBlockUid from "roamjs-components/queries/getPageUidByBlockUid"; +import apiPost from "roamjs-components/util/apiPost"; +import renderOverlay from "roamjs-components/util/renderOverlay"; +import { + GitHubDestination, + WINDOW_HEIGHT, + WINDOW_LEFT, + WINDOW_TOP, + WINDOW_WIDTH, + fetchInstallationStatus, +} from "~/components/ExportGithub"; +import { getSetting, setSetting } from "~/utils/extensionSettings"; +import nanoid from "nanoid"; +import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeByParentUid"; +import isFlagEnabled from "~/utils/isFlagEnabled"; +import getPageTitleByBlockUid from "roamjs-components/queries/getPageTitleByBlockUid"; +import { + API_URL_DEV, + GH_APP_ID_DEV, + GH_APP_ID_PROD, + GH_APP_URL_DEV, + GH_APP_URL_PROD, + GH_CLIENT_ID_DEV, + GH_CLIENT_ID_PROD, + API_URL_PROD, +} from "~/constants"; +import { getNodeEnv } from "roamjs-components/util/env"; +import sendErrorEmail from "~/utils/sendErrorEmail"; + +const CommentUidCache = new Set(); +const CommentContainerUidCache = new Set(); + +type GitHubIssuePage = { + "github-sync": { + issue: GitHubIssue; + }; +}; +type GitHubIssue = { + number: number; + createdAt: string; + updatedAt: string; + labels: string[]; + id: number; + html_url: string; + state: string; + repo: string; +}; +type GitHubCommentBlock = { + "github-sync": { + comment: GitHubComment; + }; +}; +type GitHubComment = { + id: number; + body: string; + html_url: string; + user: { + login: string; + }; + created_at: string; + updated_at: string; +}; +type GitHubCommentResponse = GitHubComment & { status: number }; +type GitHubCommentsResponse = { + data: GitHubComment[]; + status: number; +}; + +const CONFIG_PAGE = "roam/js/github-sync"; +export const SETTING = "GitHub Sync"; +let enabled = false; + +const isDev = getNodeEnv() === "development"; +// const isDev = false; +const APP_ID = isDev ? GH_APP_ID_DEV : GH_APP_ID_PROD; +const CLIENT_ID = isDev ? GH_CLIENT_ID_DEV : GH_CLIENT_ID_PROD; +const API_URL = isDev ? API_URL_DEV : API_URL_PROD; +const APP_URL = isDev ? GH_APP_URL_DEV : GH_APP_URL_PROD; + +// Utils +const getPageGitHubPropsDetails = (pageUid: string) => { + const blockProps = getBlockProps(pageUid) as GitHubIssuePage; + const issueNumber = blockProps?.["github-sync"]?.["issue"]?.["number"]; + const issueRepo = blockProps?.["github-sync"]?.["issue"]?.["repo"]; + return { issueNumber, issueRepo }; +}; +const getRoamCommentsContainerUid = async ({ + pageUid, + extensionAPI, + matchingNode, +}: { + pageUid: string; + extensionAPI: OnloadArgs["extensionAPI"]; + matchingNode?: DiscourseNode; +}) => { + try { + const pageTitle = getPageTitleByPageUid(pageUid); + + if (!matchingNode?.githubSync?.commentsQueryUid || !matchingNode) { + return; + } + + const results = await runQuery({ + extensionAPI, + parentUid: matchingNode.githubSync?.commentsQueryUid, + inputs: { NODETEXT: pageTitle, NODEUID: pageUid }, + }); + + return results.results[0]?.uid; + } catch (error) { + const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "GitHub Sync - Comments Query Execution Failed", + context: { + pageUid, + pageTitle: getPageTitleByPageUid(pageUid), + matchingNode: matchingNode?.text || "unknown", + commentsQueryUid: + matchingNode?.githubSync?.commentsQueryUid || "missing", + }, + }).catch(() => {}); + + console.error("Failed to get comments container UID:", e); + return undefined; + } +}; +export const insertNewCommentsFromGitHub = async ({ + pageUid, + extensionAPI, + matchingNode, +}: { + pageUid: string; + extensionAPI: OnloadArgs["extensionAPI"]; + matchingNode: DiscourseNode; +}) => { + const getCommentsOnPage = (pageUid: string) => { + const query = `[:find + (pull ?node [:block/string :block/uid :block/props]) + :where + [?p :block/uid "${pageUid}"] + [?node :block/page ?p] + [?node :block/props ?props] + ]`; + const results = window.roamAlphaAPI.q(query); + return results + .filter((r: any) => r[0].props["github-sync"]) + .map((r: any) => { + const node = r[0]; + return { + id: node.props["github-sync"]["comment"]["id"], + uid: node.uid, + string: node.string, + }; + }); + }; + + const { issueNumber, issueRepo } = getPageGitHubPropsDetails(pageUid); + if (!issueNumber) { + renderToast({ + id: "github-issue-comments", + content: "No Issue Number Found. Please send to GitHub first.", + }); + return; + } + + const commentsContainerUid = await getRoamCommentsContainerUid({ + pageUid, + extensionAPI, + matchingNode, + }); + + const gitHubAccessToken = getSetting("github-oauth"); + if (!gitHubAccessToken) { + renderToast({ + id: "github-issue-auth", + content: + "GitHub Authorization not found. Please re-authorize in the details window at the top of the page.", + }); + return; + } + + try { + // https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#list-issue-comments + const response = await apiGet({ + domain: "https://api.github.com", + path: `repos/${issueRepo}/issues/${issueNumber}/comments`, + headers: { + Authorization: `token ${gitHubAccessToken}`, + "Content-Type": "application/json", + }, + }); + + if (response.status === 200) { + const commentsOnGithub = response.data.map((c) => ({ + id: c.id, + body: c.body, + html_url: c.html_url, + user: { + login: c.user.login, + }, + created_at: c.created_at, + updated_at: c.updated_at, + })); + + const commentsOnPage = getCommentsOnPage(pageUid); + const commentsOnPageIds = new Set(commentsOnPage.map((gc) => gc.id)); + const newComments = commentsOnGithub.filter( + (comment) => !commentsOnPageIds.has(comment.id), + ); + + if (newComments.length === 0) { + renderToast({ + id: "github-issue-comments", + content: "No new comments found.", + }); + return; + } + + if (!commentsContainerUid) { + renderToast({ + id: "github-issue-comments", + content: "Comments Block not found. Please create one.", + }); + return; + } + + renderOverlay({ + Overlay: NewCommentsConfirmationDialog, + props: { + comments: newComments, + commentsContainerUid, + }, + }); + } + } catch (error) { + const e = error as Error; + const message = e.message; + + await sendErrorEmail({ + error: e, + type: "GitHub Sync - Fetch Comments Failed", + context: { + pageUid, + issueNumber, + issueRepo, + gitHubAccessToken: gitHubAccessToken ? "present" : "missing", + }, + }).catch(() => {}); + + renderToast({ + intent: "danger", + id: "github-issue-comments", + content: `Failed to add comments: ${ + message === "Bad credentials" + ? `${message}. Please re-authorize in the details window at the top of the page.` + : message + }`, + }); + } +}; + +export const isGitHubSyncPage = (pageTitle: string) => { + if (!enabled) return null; + + const discourseNodes = getDiscourseNodes(); + return discourseNodes.find( + (node) => + node.githubSync?.enabled && + matchDiscourseNode({ + format: node.format || "", + specification: node.specification || [], + text: node.text || "", + title: pageTitle, + }), + ); +}; + +export const renderGitHubSyncPage = async ({ + title, + h1, + onloadArgs, + matchingNode, +}: { + title: string; + h1: HTMLHeadingElement; + onloadArgs: OnloadArgs; + matchingNode: DiscourseNode; +}) => { + try { + const extensionAPI = onloadArgs.extensionAPI; + const pageUid = getPageUidByPageTitle(title); + + const commentsContainerUid = await getRoamCommentsContainerUid({ + pageUid, + extensionAPI, + matchingNode, + }); + const commentHeaderEl = document.querySelector( + `.rm-block__input[id$="${commentsContainerUid}"]`, + ); + handleTitleAdditions(h1, ); + + // Initial render for existing comments / comment container + if (commentHeaderEl && commentsContainerUid) { + const commentNodes = getShallowTreeByParentUid(commentsContainerUid); + commentNodes.map((comment) => { + const uid = comment.uid; + CommentUidCache.add(uid); + + const commentDiv = document.querySelector(`[id*="${uid}"]`); + if (commentDiv && !commentDiv.hasAttribute("github-sync-comment")) { + const containerDiv = document.createElement("div"); + containerDiv.className = "inline-block ml-2"; + containerDiv.onmousedown = (e) => e.stopPropagation(); + commentDiv.append(containerDiv); + ReactDOM.render(, containerDiv); + } + }); + + if (!commentHeaderEl.hasAttribute("github-sync-comment-container")) { + CommentContainerUidCache.add(commentsContainerUid); + commentHeaderEl.setAttribute("github-sync-comment-container", "true"); + const containerDiv = document.createElement("div"); + containerDiv.className = "inline-block ml-2"; + containerDiv.onmousedown = (e) => e.stopPropagation(); + commentHeaderEl.appendChild(containerDiv); + ReactDOM.render( + , + containerDiv, + ); + } + } + } catch (error) { + const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "GitHub Sync - Page Rendering Failed", + context: { + title, + pageUid: getPageUidByPageTitle(title), + matchingNode: matchingNode?.text || "unknown", + }, + }).catch(() => {}); + + console.error("Failed to render GitHub sync page:", e); + } +}; + +const formatComment = (c: GitHubComment) => { + const roamCreatedDate = window.roamAlphaAPI.util.dateToPageTitle( + new Date(c.created_at), + ); + const commentHeader = `${c.user.login} on [[${roamCreatedDate}]]`; + const commentBody = c.body.trim(); + return { + header: commentHeader, + body: commentBody, + props: { + "github-sync": { + comment: c, + }, + }, + }; +}; + +// Components +const CommentsComponent = ({ blockUid }: { blockUid: string }) => { + const [loading, setLoading] = useState(false); + const url = useMemo(() => { + const props = getBlockProps(blockUid) as GitHubCommentBlock; + const commentProps = props?.["github-sync"]?.["comment"]; + return commentProps?.html_url; + }, [blockUid, loading]); + + return ( + <> +