diff --git a/README.md b/README.md index dd096b8d..83eb2641 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ For more information, check out our docs at [https://github.com/RoamJS/query-bui - [Examples](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#examples) - [Discourse Graphs](https://github.com/RoamJS/query-builder/blob/main/docs/discourse-graphs.md) - [Native Roam Queries](https://github.com/RoamJS/query-builder/blob/main/docs/roam-queries.md#native-roam-queries) +- [GitHub Sync](https://github.com/RoamJS/query-builder/blob/main/docs/github-sync.md) - [Creating Native Roam Queries](https://github.com/RoamJS/query-builder/blob/main/docs/roam-queries.md#creating-native-roam-queries) - [Manipulating Native Roam Queries](https://github.com/RoamJS/query-builder/blob/main/docs/roam-queries.md#manipulating-native-roam-queries) - [Sorting](https://github.com/RoamJS/query-builder/blob/main/docs/roam-queries.md#sorting) diff --git a/docs/github-sync.md b/docs/github-sync.md new file mode 100644 index 00000000..5c239c52 --- /dev/null +++ b/docs/github-sync.md @@ -0,0 +1,56 @@ +# GitHub Sync + +This extension implemnents GitHub Sync, allowing you to synchronize specific pages directly from Roam to a specified GitHub repository as Issues. + +## Config Page + +This will be found at `[[roam/js/github-sync]]` in your graph. + +### Node Select + +Select which type of pages you want to sync to GitHub as Issues. + +The list is made from defined [Discourse Graph Nodes](discourse-graphs.md). + +### Comments Block + +This is where the comments will live. An `Add Comment` button will appear on this block as well as a download button. + +After you add a comment, that new comment block will have a `Add to GitHub` button to send it to the issue. + +Clicking the download button will grab any new comments and open a dialog to confirm adding them to the block. + +**Query Block Definition** + +Define which block will be the Comments Block. + +You can add the variables `:in NODETEXT` or `:in NODETITLE` which will grab the current pages's text or title. + +Example: + +![](media/github-sync-comment-query.png) + +## Issue Page + +### Send To GitHub + +When you first navigate to a defined Issue Page, you will see two buttons under the title + +![](media/github-sync-issue-page-title-1.png) + +Click the Send To GitHub button to start the upload process. The process is as follows: + +- install the SamePage GitHub App +- authorize the app to access your GitHub repository +- select the repository you want to send the issue to +- click `Export` + +You will only need to do this once for each page you would like to sync. + +Once this is complete, the title will just show the `GitHub Sync Details` Button. Clicking this button will show additional details about the issue, include a link to the issue, a link to settings, as well as the ability to re-authorize if required. + +### Comments + +Click the `Add Comment` button to add a comment to the page. Once the comment is created you should see a `Add to GitHub` button. Clicking this will add the comment to the issue. After it is sent, the `Add to GitHub` button should change to a `link` icon which will open the comment on GitHub. + +Click the `Download Comments` button to fetch any new comments from the issue. You will see a confirmation dialog of all the comments that will be added. diff --git a/docs/media/github-sync-comment-query.png b/docs/media/github-sync-comment-query.png new file mode 100644 index 00000000..b97883c4 Binary files /dev/null and b/docs/media/github-sync-comment-query.png differ diff --git a/docs/media/github-sync-issue-page-title-1.png b/docs/media/github-sync-issue-page-title-1.png new file mode 100644 index 00000000..fd247997 Binary files /dev/null and b/docs/media/github-sync-issue-page-title-1.png differ diff --git a/src/components/Export.tsx b/src/components/Export.tsx index 0a80c1f0..582f8f2f 100644 --- a/src/components/Export.tsx +++ b/src/components/Export.tsx @@ -45,7 +45,7 @@ import { getNodeEnv } from "roamjs-components/util/env"; import apiGet from "roamjs-components/util/apiGet"; import apiPut from "roamjs-components/util/apiPut"; import localStorageGet from "roamjs-components/util/localStorageGet"; -import { ExportGithub } from "./ExportGithub"; +import { ExportGithub, GitHubDestination } from "./ExportGithub"; import localStorageSet from "roamjs-components/util/localStorageSet"; const ExportProgress = ({ id }: { id: string }) => { @@ -79,24 +79,27 @@ const ExportProgress = ({ id }: { id: string }) => { ); }; +const EXPORT_DESTINATIONS = [ + { id: "local", label: "Download Locally", active: true }, + { id: "app", label: "Store in Roam", active: false }, + { id: "samepage", label: "Store with SamePage", active: false }, + { id: "github", label: "Send to GitHub", active: true }, +]; + export type ExportDialogProps = { results?: Result[] | ((isSamePageEnabled: boolean) => Promise); 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: "samepage", label: "Store with SamePage", active: false }, - { id: "github", label: "Send to GitHub", active: true }, -]; const exportDestinationById = Object.fromEntries( EXPORT_DESTINATIONS.map((ed) => [ed.id, ed]) ); @@ -109,10 +112,8 @@ const ExportDialog: ExportDialogComponent = ({ title = "Share Data", isExportDiscourseGraph = false, initialPanel, + initialExportDestination, }) => { - const [selectedRepo, setSelectedRepo] = useState( - localStorageGet("selected-repo") - ); const exportId = useMemo(() => nanoid(), []); useEffect(() => { setDialogOpen(isOpen); @@ -139,7 +140,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 [isSamePageEnabled, setIsSamePageEnabled] = useState(false); const checkForCanvasPage = (title: string) => { @@ -166,9 +171,6 @@ const ExportDialog: ExportDialogComponent = ({ const [includeDiscourseContext, setIncludeDiscourseContext] = useState( discourseGraphEnabled as boolean ); - const [gitHubAccessToken, setGitHubAccessToken] = useState( - localStorageGet("oauth-github") - ); const [canSendToGitHub, setCanSendToGitHub] = useState(false); const writeFileToRepo = async ({ @@ -180,9 +182,15 @@ const ExportDialog: ExportDialogComponent = ({ content: string; setError: (error: string) => void; }): Promise<{ status: number }> => { - const base64Content = btoa(content); + const gitHubAccessToken = localStorageGet("github-oauth"); + const selectedRepo = localStorageGet("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}`, @@ -195,7 +203,6 @@ const ExportDialog: ExportDialogComponent = ({ }, }); if (response.status === 401) { - setGitHubAccessToken(null); setError("Authentication failed. Please log in again."); localStorageSet("oauth-github", ""); return { status: 401 }; @@ -211,6 +218,73 @@ const ExportDialog: ExportDialogComponent = ({ return { status: 500 }; } }; + const writeFileToIssue = async ({ + title, + body, + setError, + pageUid, + }: { + title: string; + body: string; + setError: (error: string) => void; + pageUid: string; + }): Promise<{ status: number }> => { + const gitHubAccessToken = localStorageGet("github-oauth"); + const selectedRepo = localStorageGet("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."); + localStorageSet("oauth-github", ""); + 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); @@ -493,15 +567,12 @@ const ExportDialog: ExportDialogComponent = ({ onItemSelect={(et) => setActiveExportDestination(et)} /> - + {activeExportDestination === "github" && ( + + )} @@ -622,17 +693,41 @@ const ExportDialog: ExportDialogComponent = ({ if (activeExportDestination === "github") { const { title, content } = files[0]; + const githubDestination = + localStorageGet("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/src/components/ExportGithub.tsx b/src/components/ExportGithub.tsx index b9aca04f..c770df66 100644 --- a/src/components/ExportGithub.tsx +++ b/src/components/ExportGithub.tsx @@ -1,20 +1,14 @@ -import { Button } from "@blueprintjs/core"; +import { Button, Label } from "@blueprintjs/core"; import nanoid from "nanoid"; -import React, { - useCallback, - useEffect, - useRef, - useState, - useMemo, -} from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import MenuItemSelect from "roamjs-components/components/MenuItemSelect"; import apiGet from "roamjs-components/util/apiGet"; -import apiPost from "roamjs-components/util/apiPost"; import { getNodeEnv } from "roamjs-components/util/env"; +import apiPost from "roamjs-components/util/apiPost"; import localStorageGet from "roamjs-components/util/localStorageGet"; import localStorageSet from "roamjs-components/util/localStorageSet"; -type UserReposResponse = { +export type UserReposResponse = { data: [ { name: string; @@ -23,29 +17,47 @@ 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; + +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 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 fetchInstallationStatus = async () => { + 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 ${localStorageGet("github-oauth")}`, + }, + }); + 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 + ); + return isAppInstalled; + } catch (error) { + const e = error as Error; + 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,18 +66,34 @@ export const ExportGithub = ({ const [clickedInstall, setClickedInstall] = useState(false); const [repos, setRepos] = useState(initialRepos); const [state, setState] = useState(""); + const [gitHubAccessToken, _setGitHubAccessToken] = useState( + localStorageGet("github-oauth") + ); + const [githubDestination, _setGithubDestination] = + useState( + (localStorageGet("github-destination") as GitHubDestination) || "File" + ); + const [selectedRepo, _setSelectedRepo] = useState( + localStorageGet("github-repo") + ); const showGitHubLogin = isGitHubAppInstalled && !gitHubAccessToken; - const repoSelectEnabled = isGitHubAppInstalled && gitHubAccessToken; - - const isDev = useMemo(() => getNodeEnv() === "development", []); + const repoAndDestinationSelectEnabled = + isGitHubAppInstalled && gitHubAccessToken; - const setRepo = (repo: string) => { - setSelectedRepo(repo); - localStorageSet("selected-repo", repo); + const setGitHubAccessToken = (token: string) => { + localStorageSet("github-oauth", token); + _setGitHubAccessToken(token); + }; + const setGithubDestination = (destination: GitHubDestination) => { + localStorageSet("github-destination", destination); + _setGithubDestination(destination); + }; + const setSelectedRepo = (repo: string) => { + localStorageSet("github-repo", repo); + _setSelectedRepo(repo); }; const handleReceivedAccessToken = (token: string) => { - localStorageSet("oauth-github", token); setGitHubAccessToken(token); setClickedInstall(false); authWindow.current?.close(); @@ -73,27 +101,13 @@ export const ExportGithub = ({ const fetchAndSetInstallation = useCallback(async () => { try { - const res = await apiGet<{ installations: { app_id: number }[] }>({ - domain: "https://api.github.com", - path: "user/installations", - headers: { - Authorization: `token ${localStorageGet("oauth-github")}`, - }, - }); - 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(); setIsGitHubAppInstalled(isAppInstalled); - return isAppInstalled; } catch (error) { const e = error as Error; if (e.message === "Bad credentials") { - setGitHubAccessToken(null); - localStorageSet("oauth-github", ""); + setGitHubAccessToken(""); } - return false; } }, []); @@ -112,16 +126,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(() => { @@ -133,6 +142,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", @@ -156,42 +166,43 @@ export const ExportGithub = ({ } }, [gitHubAccessToken, isGitHubAppInstalled, selectedRepo]); - if (!isVisible) return null; return (
-
- {!isGitHubAppInstalled && ( -
+ {(!isGitHubAppInstalled || clickedInstall) && ( +
+ {!isGitHubAppInstalled && ( +
+ )} {showGitHubLogin && (
); diff --git a/src/components/GitHubSync.tsx b/src/components/GitHubSync.tsx new file mode 100644 index 00000000..f70ae832 --- /dev/null +++ b/src/components/GitHubSync.tsx @@ -0,0 +1,1025 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import getDiscourseNodes from "../utils/getDiscourseNodes"; +import matchDiscourseNode from "../utils/matchDiscourseNode"; +import { OnloadArgs, PullBlock, 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 { createConfigObserver } from "roamjs-components/components/ConfigPage"; +import SelectPanel from "roamjs-components/components/ConfigPanels/SelectPanel"; +import { render as exportRender } from "../components/Export"; +import getBlockProps from "../utils/getBlockProps"; +import localStorageGet from "roamjs-components/util/localStorageGet"; +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 "./ExportGithub"; +import localStorageSet from "roamjs-components/util/localStorageSet"; +import { getNodeEnv } from "roamjs-components/util/env"; +import nanoid from "nanoid"; +import { + CustomField, + Field, +} from "roamjs-components/components/ConfigPanels/types"; +import CustomPanel from "roamjs-components/components/ConfigPanels/CustomPanel"; +import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeByParentUid"; +import CommentsQuery from "./GitHubSyncCommentsQuery"; +import getSubTree from "roamjs-components/util/getSubTree"; + +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"; +const SETTING = "github-sync"; +let enabled = false; + +// 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, +}: { + pageUid: string; + extensionAPI: OnloadArgs["extensionAPI"]; +}) => { + const pageTitle = getPageTitleByPageUid(pageUid); + const configUid = getPageUidByPageTitle(CONFIG_PAGE); + const configTree = getBasicTreeByParentUid(configUid); + const queryNode = getSubTree({ + tree: configTree, + key: "Comments Block", + }); + if (!queryNode) { + renderToast({ + id: "github-issue-comments", + content: `Comments Block query not set. Set it in ${CONFIG_PAGE}`, + }); + return; + } + const results = await runQuery({ + extensionAPI, + parentUid: queryNode.uid, + inputs: { NODETEXT: pageTitle, NODEUID: pageUid }, + }); + + return results.results[0]?.uid; +}; +export const insertNewCommentsFromGitHub = async ({ + pageUid, + extensionAPI, +}: { + pageUid: string; + extensionAPI: OnloadArgs["extensionAPI"]; +}) => { + 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, + }); + + const gitHubAccessToken = localStorageGet("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; + 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; + const gitHubNodeResult = window.roamAlphaAPI.data.fast.q(`[:find + (pull ?node [:block/string]) + :where + [?roamjsgithub-sync :node/title "roam/js/github-sync"] + [?node :block/page ?roamjsgithub-sync] + [?p :block/children ?node] + (or [?p :block/string ?p-String] + [?p :node/title ?p-String]) + [(clojure.string/includes? ?p-String "Node Select")] + ]`) as [PullBlock][]; + const nodeText = gitHubNodeResult[0]?.[0]?.[":block/string"] || ""; + if (!nodeText) return; + + const discourseNodes = getDiscourseNodes(); + const selectedNode = discourseNodes.find((node) => node.text === nodeText); + const isPageTypeOfNode = matchDiscourseNode({ + format: selectedNode?.format || "", + specification: selectedNode?.specification || [], + text: selectedNode?.text || "", + title: pageTitle, + }); + return isPageTypeOfNode; +}; + +export const renderGitHubSyncPage = async ({ + h1, + pageUid, + onloadArgs, +}: { + h1: HTMLHeadingElement; + pageUid: string; + onloadArgs: OnloadArgs; +}) => { + const extensionAPI = onloadArgs.extensionAPI; + + const commentsContainerUid = await getRoamCommentsContainerUid({ + pageUid, + extensionAPI, + }); + 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 + ); + } + } +}; + +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 ( + <> +