diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 927cd4cb..9aef1086 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -1,5 +1,8 @@ name: Publish Extension -on: pull_request + +on: + pull_request: + workflow_dispatch: env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} diff --git a/package-lock.json b/package-lock.json index b09d775f..da3aea5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@nanopub/sign": "^0.1.4", "@samepage/external": "^0.71.10", "@tldraw/tldraw": "^2.0.0-alpha.12", "contrast-color": "^1.0.1", @@ -1965,6 +1966,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nanopub/sign": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@nanopub/sign/-/sign-0.1.4.tgz", + "integrity": "sha512-QoPBC5Fg20GcTPz/Rtlkq3E8DnwpsAIKSLaTpTp/1bTWY8ZlHZh1ik4pQSZZfUwOWiNtZTwdiO2/GtKY0krZkw==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -10943,6 +10949,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@nanopub/sign": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@nanopub/sign/-/sign-0.1.4.tgz", + "integrity": "sha512-QoPBC5Fg20GcTPz/Rtlkq3E8DnwpsAIKSLaTpTp/1bTWY8ZlHZh1ik4pQSZZfUwOWiNtZTwdiO2/GtKY0krZkw==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 6e2cc107..4b10c731 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "widgets" ], "dependencies": { + "@nanopub/sign": "^0.1.4", "@samepage/external": "^0.71.10", "@tldraw/tldraw": "^2.0.0-alpha.12", "contrast-color": "^1.0.1", diff --git a/src/components/Export.tsx b/src/components/Export.tsx index babaf008..5defb66f 100644 --- a/src/components/Export.tsx +++ b/src/components/Export.tsx @@ -50,6 +50,8 @@ import localStorageSet from "roamjs-components/util/localStorageSet"; import isLiveBlock from "roamjs-components/queries/isLiveBlock"; import createPage from "roamjs-components/writes/createPage"; import { createInitialTldrawProps } from "../utils/createInitialTldrawProps"; +import { render as renderNanopub } from "./nanopub/ExportNanopub"; +import { OnloadArgs } from "roamjs-components/types"; const ExportProgress = ({ id }: { id: string }) => { const [progress, setProgress] = useState(0); @@ -99,6 +101,7 @@ const EXPORT_DESTINATIONS = [ { id: "app", label: "Store in Roam", active: false }, { id: "samepage", label: "Store with SamePage", active: false }, { id: "github", label: "Send to GitHub", active: true }, + { id: "nanopub", label: "Publish as Nanopub", active: true }, ]; const SEND_TO_DESTINATIONS = ["page", "graph"]; @@ -525,6 +528,7 @@ const ExportDialog: ExportDialogComponent = ({ items={exportTypes.map((e) => e.name)} activeItem={activeExportType} onItemSelect={(et) => setActiveExportType(et)} + disabled={activeExportType === "nanopub"} />
@@ -646,6 +650,16 @@ const ExportDialog: ExportDialogComponent = ({ const exportType = exportTypes.find( (e) => e.name === activeExportType ); + + if (activeExportDestination === "nanopub") { + const allResults = + typeof results === "function" + ? await results(isSamePageEnabled) + : results || []; + handleNanopubExport(allResults, getExtensionAPI()); + return; + } + if (exportType && window.RoamLazy) { setDialogOpen(true); setLoading(false); @@ -804,6 +818,18 @@ const ExportDialog: ExportDialogComponent = ({ ); + const handleNanopubExport = ( + results: Result[], + extensionAPI: OnloadArgs["extensionAPI"] + ) => { + onClose(); + renderNanopub({ + results, + onClose: () => {}, + extensionAPI, + }); + }; + return ( <> void; hideCustomSwitch?: boolean; showAlias?: boolean; + hideQueryButton?: boolean; }) => JSX.Element; const QueryEditor: QueryEditorComponent = ({ @@ -445,6 +446,7 @@ const QueryEditor: QueryEditorComponent = ({ setHasResults, hideCustomSwitch, showAlias, + hideQueryButton, }) => { useEffect(() => { const previewQuery = ((e: CustomEvent) => { @@ -896,7 +898,10 @@ const QueryEditor: QueryEditorComponent = ({ onBlur={() => setShowDisabledMessage(false)} text={"Query"} onClick={disabledMessage ? undefined : onQuery} - className={disabledMessage ? "bp3-disabled" : ""} + className={` + ${disabledMessage ? "bp3-disabled" : ""} + ${hideQueryButton ? "hidden" : ""} + `} style={{ maxHeight: 32, }} diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx new file mode 100644 index 00000000..0b884e03 --- /dev/null +++ b/src/components/SettingsDialog.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { + Dialog, + Button, + Classes, + IconName, + MaybeElement, +} from "@blueprintjs/core"; +import renderOverlay from "roamjs-components/util/renderOverlay"; + +type SelectItem = { + id: string; + text: string; + icon?: IconName | MaybeElement; + onClick: () => void; +}; + +type SelectDialogProps = { + isOpen: boolean; + onClose: () => void; + title: string; + items: SelectItem[]; + errorMessage?: string; + width?: string; +}; + +const SelectDialog = ({ + isOpen, + onClose, + title, + items, + errorMessage, + width = "20rem", +}: SelectDialogProps) => { + return ( + +
+
+
{title}
+
+ )) + ) : ( +
+ {errorMessage || "No items available"} +
+ )} +
+
+ + ); +}; + +export const renderSelectDialog = (props: SelectDialogProps) => + renderOverlay({ Overlay: SelectDialog, props }); diff --git a/src/components/nanopub/ContributorManager.tsx b/src/components/nanopub/ContributorManager.tsx new file mode 100644 index 00000000..af8b7fb2 --- /dev/null +++ b/src/components/nanopub/ContributorManager.tsx @@ -0,0 +1,323 @@ +import React, { useCallback, useMemo, useRef, useEffect, memo } from "react"; +import { Button, Intent } from "@blueprintjs/core"; +import { MultiSelect } from "@blueprintjs/select"; +import MenuItemSelect from "roamjs-components/components/MenuItemSelect"; +import { PullBlock } from "roamjs-components/types"; +import nanoid from "nanoid"; +import { Contributor, NanopubPage } from "./Nanopub"; +import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import getSubTree from "roamjs-components/util/getSubTree"; +import { PossibleContributor } from "./NanopubMainConfig"; +import getCurrentUserDisplayName from "roamjs-components/queries/getCurrentUserDisplayName"; + +// https://credit.niso.org/ taxonomy roles + +export type CreditRole = { + label: string; + uri: string; + verb: string; +}; +export const creditRoles: CreditRole[] = [ + { + uri: "conceptualization", + label: "Conceptualization", + verb: "Conceptualized by", + }, + { + uri: "data-curation", + label: "Data curation", + verb: "Data curated by", + }, + { + uri: "formal-analysis", + label: "Formal analysis", + verb: "Formal analysis performed by", + }, + { + uri: "funding-acquisition", + label: "Funding acquisition", + verb: "Funding acquired by", + }, + { + uri: "investigation", + label: "Investigation", + verb: "Investigated by", + }, + { + uri: "methodology", + label: "Methodology", + verb: "Methodology developed by", + }, + { + uri: "project-administration", + label: "Project administration", + verb: "Project administered by", + }, + { + uri: "software", + label: "Software", + verb: "Software developed by", + }, + { + uri: "resources", + label: "Resources", + verb: "Resources provided by", + }, + { + uri: "supervision", + label: "Supervision", + verb: "Supervised by", + }, + { + uri: "validation", + label: "Validation", + verb: "Validated by", + }, + { + uri: "visualization", + label: "Visualization", + verb: "Visualization created by", + }, + { + uri: "writing-original-draft", + label: "Writing – original draft", + verb: "Original draft written by", + }, + { + uri: "writing-review-editing", + label: "Writing – review & editing", + verb: "Reviewed and edited by", + }, +]; + +export const getContributors = (): PossibleContributor[] => { + const discourseConfigUid = getPageUidByPageTitle("roam/js/discourse-graph"); + const tree = getBasicTreeByParentUid(discourseConfigUid); + const nanoPubTree = getSubTree({ tree, key: "Nanopub" }); + if (!nanoPubTree.children.length) return []; + const contributorsNode = getSubTree({ + tree: nanoPubTree.children, + key: "contributors", + }); + return contributorsNode.children + .map((c) => ({ + uid: c.uid, + name: c.text, + orcid: c.children[0]?.text, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +}; + +export const getCurrentUserOrcid = (): string => { + const contributors = getContributors(); + const name = getCurrentUserDisplayName(); + const contributor = contributors.find((c) => c.name === name); + return contributor?.orcid || ""; +}; + +const ContributorManager = ({ + pageUid, + pageProps: props, + node, + contributors, + setContributors, + requireContributors = false, + handleClose, +}: { + pageUid: string; + pageProps: Record; + node: string; + contributors: Contributor[]; + setContributors: React.Dispatch>; + requireContributors?: boolean; + handleClose: () => void; +}) => { + const debounceRef = useRef(0); + const nanopubProps = props["nanopub"] as NanopubPage; + const possibleContributorNames = useMemo(() => { + const definedContributors = getContributors() || []; + return definedContributors.filter( + (c) => !contributors.some((existing) => existing.name === c.name) + ); + }, [contributors]); + + const updateContributorProps = useCallback( + (newContributors: Contributor[]) => { + window.clearTimeout(debounceRef.current); + debounceRef.current = window.setTimeout(() => { + window.roamAlphaAPI.updateBlock({ + block: { + uid: pageUid, + props: { + ...props, + nanopub: { ...nanopubProps, contributors: newContributors }, + }, + }, + }); + }, 1000); + }, + [pageUid, props, nanopubProps] + ); + useEffect(() => { + updateContributorProps(contributors); + }, [contributors, updateContributorProps]); + + return ( + <> + +
+
+ {contributors.map((contributor, index) => ( + + ))} +
+
+ + {requireContributors && contributors.length === 0 ? ( + (required) + ) : contributors.length === 0 ? ( + (optional) + ) : null} +
+
+ + ); +}; + +const ContributorRow = memo( + ({ + contributor, + key, + // isEditing, + possibleContributors, + setContributors, + }: { + contributor: Contributor; + key: string; + // isEditing: boolean; + setContributors: React.Dispatch>; + possibleContributors: PossibleContributor[]; + }) => { + const debounceRef = useRef(0); + const setContributor = useCallback( + (newName: string, timeout: boolean = true) => { + window.clearTimeout(debounceRef.current); + debounceRef.current = window.setTimeout( + () => { + // this is susceptible to duplicate names + const newOrcid = + possibleContributors.find((c) => c.name === newName)?.orcid || ""; + setContributors((_contributors) => + _contributors.map((con) => + con.id === contributor.id + ? { ...con, name: newName, orcid: newOrcid } + : con + ) + ); + }, + timeout ? 250 : 0 + ); + }, + [contributor.id, setContributors] + ); + + const setContributorRoles = useCallback( + (v, contributor, remove = false) => { + setContributors((_contributors) => + _contributors.map((c) => + contributor.id === c.id + ? { + ...c, + roles: remove + ? c.roles?.filter((r) => r !== v) + : [...(c.roles || []), v], + } + : c + ) + ); + }, + [] + ); + + const removeContributor = useCallback(() => { + setContributors((_contributors) => + _contributors.filter((c) => c.id !== contributor.id) + ); + }, [contributor.id, setContributors]); + + return ( +
+
+ c.name)} + onItemSelect={(item, event) => { + console.log(event); + setContributor(item); + }} + filterable={true} + activeItem={contributor.name} + className="contributor-name-select" + /> +
+ role.label)} + selectedItems={contributor.roles} + onItemSelect={(item) => setContributorRoles(item, contributor)} + tagRenderer={(item) => item} + popoverProps={{ minimal: true }} + itemListRenderer={({ items, renderItem }) => { + return
{items.map(renderItem)}
; + }} + itemRenderer={(item, { modifiers, handleClick }) => { + if (contributor.roles?.includes(item)) return null; + if (!modifiers.matchesPredicate) return null; + return ( +
+ ); + } +); + +export default ContributorManager; diff --git a/src/components/nanopub/ExportNanopub.tsx b/src/components/nanopub/ExportNanopub.tsx new file mode 100644 index 00000000..1de54727 --- /dev/null +++ b/src/components/nanopub/ExportNanopub.tsx @@ -0,0 +1,357 @@ +import React, { useState, useMemo } from "react"; +import { + Dialog, + Classes, + HTMLTable, + Tag, + Button, + Card, + H2, + Checkbox, + Tooltip, +} from "@blueprintjs/core"; +import { Result } from "roamjs-components/types/query-builder"; +import renderOverlay from "roamjs-components/util/renderOverlay"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import getDiscourseNodes, { + DiscourseNode, +} from "../../utils/getDiscourseNodes"; +import findDiscourseNode from "../../utils/findDiscourseNode"; +import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; +import getBlockProps from "../../utils/getBlockProps"; +import { Contributor, NanopubPage } from "./Nanopub"; +import NanopubConfigPanel from "./NanopubNodeConfig"; +import getSubTree from "roamjs-components/util/getSubTree"; +import refreshConfigTree from "../../utils/refreshConfigTree"; +import ContributorManager from "./ContributorManager"; +import PreviewNanopub from "./PreviewNanopub"; +import { OnloadArgs } from "roamjs-components/types"; + +// TODO +// go over all possible double checks +// eg: ORCID missing + +type NodeResult = { + text: string; + uid: string; + node: DiscourseNode; + nanopub: NanopubPage; +}; + +const InternalContributorManager = ({ node }: { node: NodeResult }) => { + const discourseNode = node.node; + const props = useMemo( + () => getBlockProps(node.uid) as Record, + [node.uid] + ); + const nanopub = props["nanopub"] as NanopubPage; + const initialContributors = nanopub?.contributors || []; + const [contributors, setContributors] = + useState(initialContributors); + return ( + {}} + requireContributors={discourseNode.nanopub?.requireContributors} + /> + ); +}; + +const ExportNanopub = ({ + results, + onClose, + extensionAPI, +}: { + results: Result[]; + onClose: () => void; + extensionAPI: OnloadArgs["extensionAPI"]; +}) => { + const [isOpen, setIsOpen] = useState(true); + const [nodes, setNodes] = useState(getDiscourseNodes()); + const [viewNanopubConfigNodeType, setViewNanopubConfigNodeType] = + useState(null); + const [viewContributorsNodeResult, setViewContributorsNodeResult] = + useState(null); + const [previewNanopub, setPreviewNanopub] = useState(null); + + const transformResults = (results: Result[]) => { + const nodes = getDiscourseNodes(); + return results + .map((r) => { + const node = findDiscourseNode(r.uid, nodes, false); + const props = getBlockProps(r.uid) as Record; + const nanopub = props["nanopub"] as NanopubPage; + return node ? { ...r, node, nanopub } : null; + }) + .filter((r) => r?.node?.backedBy !== "default") + .filter((r) => r !== null); + }; + + const [nodeResults, setNodeResults] = useState( + transformResults(results) + ); + + const checkAndCalcUidsToBePublished = ( + nodeResults: NodeResult[], + currentUids?: string[] + ) => { + const eligibleUids = nodeResults + .filter((r) => { + const { nanopub: nanopubSettings } = r.node; + const { contributors, published } = r.nanopub || {}; + const isEnabled = nanopubSettings?.enabled; + const hasRequiredContributors = + !nanopubSettings?.requireContributors || + (nanopubSettings.requireContributors && contributors?.length > 0); + return isEnabled && hasRequiredContributors && !published; + }) + .map((r) => r.uid); + + if (currentUids) { + return currentUids.filter((uid) => eligibleUids.includes(uid)); + } + + return eligibleUids; + }; + const [uidsToBePublished, setUidsToBePublished] = useState( + checkAndCalcUidsToBePublished(nodeResults) + ); + + const refresh = () => { + refreshConfigTree(); + const newResults = transformResults(results); + setNodeResults(newResults); + setUidsToBePublished( + checkAndCalcUidsToBePublished(newResults, uidsToBePublished) + ); + }; + + return ( + <> + +
+ {viewNanopubConfigNodeType ? ( + <> +
+
+

{viewNanopubConfigNodeType.text} Nanopub Config

+ + + + + ) : viewContributorsNodeResult ? ( + <> +
+
+

Contributors

+ + + ) : previewNanopub ? ( + <> +
+
+
+ + +
+
+
+ + ); +}; + +export type ExportNanopubProps = { + results: Result[]; + onClose: () => void; + extensionAPI: OnloadArgs["extensionAPI"]; +}; +export const render = (props: ExportNanopubProps) => + renderOverlay({ Overlay: ExportNanopub, props }); diff --git a/src/components/nanopub/Nanopub.tsx b/src/components/nanopub/Nanopub.tsx new file mode 100644 index 00000000..bedb1f96 --- /dev/null +++ b/src/components/nanopub/Nanopub.tsx @@ -0,0 +1,921 @@ +import React, { useEffect, useState, useMemo, useCallback } from "react"; +import { + Dialog, + Button, + TextArea, + Tabs, + Tab, + TabId, + Label, + Tag, + Text, + FormGroup, + InputGroup, + Classes, +} from "@blueprintjs/core"; +// web.js:1014 Uncaught (in promise) TypeError: Failed to construct 'URL': Invalid URL +// at __wbg_init (web.js:1014:17) +// at Nanopub.tsx:200:7 +// import init, { KeyPair, Nanopub, NpProfile, getNpServer } from "@nanopub/sign"; +import init, { + Nanopub, + NpProfile, + getNpServer, + KeyPair, + // @ts-ignore +} from "https://unpkg.com/@nanopub/sign"; +import renderOverlay from "roamjs-components/util/renderOverlay"; +import { OnloadArgs } from "roamjs-components/types"; +import getDiscourseNode from "../../utils/getDiscourseNode"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import getCurrentUserDisplayName from "roamjs-components/queries/getCurrentUserDisplayName"; +import ContributorManager, { + creditRoles, + getCurrentUserOrcid, +} from "./ContributorManager"; +import { + baseRdf, + defaultPredicates, + NanopubTripleType, + PredicateKey, +} from "./NanopubNodeConfig"; +import getBlockProps from "../../utils/getBlockProps"; +import getExportTypes, { + extractContentFromFormat, +} from "../../utils/getExportTypes"; +import { DiscourseNode, NanopubConfig } from "../../utils/getDiscourseNodes"; +import apiPost from "roamjs-components/util/apiPost"; +import { getExportSettings } from "../../utils/getExportSettings"; +import { getNodeEnv } from "roamjs-components/util/env"; +import runQuery from "../../utils/runQuery"; +import PreviewNanopub from "./PreviewNanopub"; +import SourceManager from "./SourceManager"; + +export type NanopubPage = { + contributors: Contributor[]; + source?: string; + published?: string; +}; +export type Contributor = { + id: string; + name: string; + orcid: string; + roles: string[]; +}; + +// TEMP PRIVATE KEY +const PRIVATE_KEY = + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCjY1gsFxmak6SOCouJPuEzHNForkqFhgfHE3aAIAx+Y5q6UDEDM9Q0EksheNffJB4iPqsAfiFpY0ARQY92K5r8P4+a78eu9reYrb2WxZb1qPJmvR7XZ6sN1oHD7dd/EyQoJmQsmOKdrqaLRbzR7tZrf52yvKkwNWXcIVhW8uxe7iUgxiojZpW9srKoK/qFRpaUZSKn7Z/zgtDH9FJkYbBsGPDMqp78Kzt+sJb+U2W+wCSSy34jIUxx6QRbzvn6uexc/emFw/1DU5y7zBudhgC7mVk8vX1gUNKyjZBzlOmRcretrANgffqs5fx/TMHN1xtkA/H1u1IKBfKoyk/xThMLAgMBAAECggEAECuG0GZA3HF8OaqFgMG+W+agOvH04h4Pqv4cHjYNxnxpFcNV9nEssTKWSOvCwYy7hrwZBGV3PQzbjFmmrxVFs20+8yCD7KbyKKQZPVC0zf84bj6NTNgvr6DpGtDxINxuGaMjCt7enqhoRyRRuZ0fj2gD3Wqae/Ds8cpDCefkyMg0TvauHSUj244vGq5nt93txUv1Sa+/8tWZ77Dm0s5a3wUYB2IeAMl5WrO2GMvgzwH+zT+4kvNWg5S0Ze4KE+dG3lSIYZjo99h14LcQS9eALC/VBcAJ6pRXaCTT/TULtcLNeOpoc9Fu25f0yTsDt6Ga5ApliYkb7rDhV+OFrw1sYQKBgQDCE9so+dPg7qbp0cV+lbb7rrV43m5s9Klq0riS7u8m71oTwhmvm6gSLfjzqb8GLrmflCK4lKPDSTdwyvd+2SSmOXySw94zr1Pvc7sHdmMRyA7mH3m+zSOOgyCTTKyhDRCNcRIkysoL+DecDhNo4Fumf71tsqDYogfxpAQhn0re8wKBgQDXhMmmT2oXiMnYHhi2k7CJe3HUqkZgmW4W44SWqKHp0V6sjcHm0N0RT5Hz1BFFUd5Y0ZB3JLcah19myD1kKYCj7xz6oVLb8O7LeAZNlb0FsrtD7NU+Hciywo8qESiA7UYDkU6+hsmxaI01DsttMIdG4lSBbEjA7t4IQC5lyr7xiQKBgQCN87YGJ40Y5ZXCSgOZDepz9hqX2KGOIfnUv2HvXsIfiUwqTXs6HbD18xg3KL4myIBOvywSM+4ABYp+foY+Cpcq2btLIeZhiWjsKIrw71+Q/vIe0YDb1PGf6DsoYhmWBpdHzR9HN+hGjvwlsYny2L9Qbfhgxxmsuf7zeFLpQLijjwKBgH7TD28k8IOk5VKec2CNjKd600OYaA3UfCpP/OhDl/RmVtYoHWDcrBrRvkvEEd2/DZ8qw165Zl7gJs3vK+FTYvYVcfIzGPWA1KU7nkntwewmf3i7V8lT8ZTwVRsmObWU60ySJ8qKuwoBQodki2VX12NpMN1wgWe3qUUlr6gLJU4xAoGAet6nD3QKwk6TTmcGVfSWOzvpaDEzGkXjCLaxLKh9GreM/OE+h5aN2gUoFeQapG5rUwI/7Qq0xiLbRXw+OmfAoV2XKv7iI8DjdIh0F06mlEAwQ/B0CpbqkuuxphIbchtdcz/5ra233r3BMNIqBl3VDDVoJlgHPg9msOTRy13lFqc="; + +export const NanoPubTitleButtons = ({ + uid, + onloadArgs, +}: { + uid: string; + onloadArgs: OnloadArgs; +}) => { + return ( +
+
+ ); +}; + +export const NanopubTriple = ({ + subject, + object, + predicate, +}: { + subject: string; + object: string; + predicate: string; +}) => ( +
+ + + +
+); + +const getPageContent = async ({ + pageTitle, + uid, +}: { + pageTitle: string; + uid: string; +}): Promise => { + const htmlExport = getExportTypes({ + exportId: "nanopub", + results: [{ text: pageTitle, uid }], + isExportDiscourseGraph: false, + }).find((type) => type.name === "HTML"); + if (!htmlExport) return ""; + const result = await htmlExport.callback({ + isSamePageEnabled: false, + includeDiscourseContext: false, + filename: "", + isExportDiscourseGraph: false, + settings: { + frontmatter: [], + }, + }); + const { content } = result[0]; + const bodyContent = content.match(/([\s\S]*)<\/body>/i)?.[0] || ""; + + return bodyContent; +}; + +export const updateObjectPlaceholders = async ({ + object, + pageUid, + nanopubConfig, + extensionAPI, + orcid, +}: { + object: string; + pageUid: string; + nanopubConfig?: NanopubConfig; + extensionAPI: any; + orcid: string; +}) => { + const pageTitle = getPageTitleByPageUid(pageUid); + const pageUrl = `https://roamresearch.com/#/app/${window.roamAlphaAPI.graph.name}/page/${pageUid}`; + const orcidUrl = `https://orcid.org/${orcid}`; + + let contentUid = pageUid; + if (nanopubConfig?.useCustomBody) { + const results = await runQuery({ + extensionAPI, + parentUid: nanopubConfig?.customBodyUid, + inputs: { NODETEXT: pageTitle, NODEUID: pageUid }, + }); + + contentUid = results.results[0]?.uid; + } + // use exportSettings? or just enforce simplifiedTitle? + // const { simplifiedFilename } = getExportSettings(); + // const title = simplifiedFilename + // ? extractContentFromFormat({ title: pageTitle }) + // : pageTitle; + + return object + .replace(/\{nodeType\}/g, nanopubConfig?.nodeType || "") + .replace(/\{title\}/g, extractContentFromFormat({ title: pageTitle })) + .replace(/\{url\}/g, pageUrl) + .replace(/\{myORCID\}/g, orcidUrl) + .replace(/\{createdBy\}/g, orcidUrl) + .replace(/\{body\}/g, await getPageContent({ pageTitle, uid: contentUid })); +}; + +const NanopubDialog = ({ + uid, + onloadArgs, +}: { + uid: string; + onloadArgs: any; +}) => { + const extensionAPI = onloadArgs.extensionAPI; + const [isOpen, setIsOpen] = useState(true); + const handleClose = () => setIsOpen(false); + const [rdfString, setRdfString] = useState(""); + const [error, setError] = useState(""); + const props = useMemo( + () => getBlockProps(uid) as Record, + [uid] + ); + const nanopub = props["nanopub"] as NanopubPage; + const initialContributors = nanopub?.contributors || []; + const initialSource = nanopub?.source; + const propsUrl = nanopub?.published; + const [contributors, setContributors] = + useState(initialContributors); + const [source, setSource] = useState(initialSource || ""); + const [publishedURL, setPublishedURL] = useState(propsUrl); + const [selectedTabId, setSelectedTabId] = useState("nanopub-details"); + const [discourseNode, setDiscourseNode] = useState( + null + ); + useEffect(() => { + const node = getDiscourseNode({ uid, cache: false }); + setDiscourseNode(node); + }, []); + const nanopubConfig = discourseNode?.nanopub; + const templateTriples = nanopubConfig?.triples; + + const generateRdfString = async ({ + triples, + isExample = false, // TEMP + }: { + triples: NanopubTripleType[]; + isExample?: boolean; + }): Promise => { + const rdf = { ...baseRdf }; + + const updatePlaceHolderProps = { + pageUid: uid, + nanopubConfig, + extensionAPI, + orcid, + }; + + const objectIdentifierIds = [ + "rdf:type", + "foaf:page", + "dc:creator", + "prov:wasAttributedTo", + ]; + + const createGraph = async (type: string, idPrefix: string) => { + const relevantTriples = triples.filter((triple) => triple.type === type); + + const triplePromises = relevantTriples.map(async (triple) => { + const predicate = defaultPredicates[triple.predicate as PredicateKey]; + const objectIdentifier = objectIdentifierIds.includes(predicate) + ? "@id" + : "@value"; + const objectvalue = await updateObjectPlaceholders({ + object: triple.object, + ...updatePlaceHolderProps, + }); + + return { + [predicate]: { [objectIdentifier]: objectvalue }, + }; + }); + + const tripleObjects = await Promise.all(triplePromises); + + return [ + { + "@id": idPrefix, + ...Object.assign({}, ...tripleObjects), + }, + ]; + }; + + rdf["@graph"]["np:hasAssertion"]["@graph"] = await createGraph( + "assertion", + `#${discourseNode?.text}` + ); + rdf["@graph"]["np:hasProvenance"]["@graph"] = await createGraph( + "provenance", + "#assertion" + ); + rdf["@graph"]["np:hasPublicationInfo"]["@graph"] = await createGraph( + "publication info", + "#" + ); + + const pubInfoGraph = rdf["@graph"]["np:hasPublicationInfo"]["@graph"]; + + // Add timestamp to publication info + pubInfoGraph.push({ + "@id": "#", + "dc:created": { + "@value": new Date().toISOString(), + "@type": "xsd:dateTime", + }, + }); + + // Alias predicates and contributor roles + const addAliases = ({ + id, + label, + prefix, + }: { + id: string; + label: string; + prefix: string; + }) => { + pubInfoGraph.push({ + "@id": prefix + id, + "rdfs:label": label, + }); + }; + Object.entries(defaultPredicates).forEach(([key, value]) => { + addAliases({ + id: value, + label: key, + prefix: "", + }); + }); + + creditRoles.forEach((role) => { + addAliases({ + id: role.uri, + label: role.verb, + prefix: "credit:", + }); + }); + + addAliases({ + id: "prov:wasDerivedFrom", + label: "has source", + prefix: "", + }); + + // Add contributors to provenance + if (contributors.length) { + const props = getBlockProps(uid) as Record; + const nanopub = props["nanopub"] as NanopubPage; + const contributors = nanopub?.contributors || []; + + const provenanceGraph = rdf["@graph"]["np:hasProvenance"]["@graph"]; + + // Add contributors with their ORCIDs and names as aliases + contributors.forEach((contributor) => { + if (!contributor.orcid) return; // Skip if no ORCID + + // Add roles using ORCID as identifier + contributor.roles.forEach((role) => { + const roleUri = creditRoles.find((r) => r.label === role)?.uri; + if (roleUri) { + provenanceGraph.push({ + "@id": "#assertion", + [`credit:${roleUri}`]: { + "@id": `https://orcid.org/${contributor.orcid}`, + }, + }); + } + }); + }); + } + + // Alias ORCID + if (orcidUrl) { + addAliases({ + id: orcidUrl, + label: getCurrentUserDisplayName(), + prefix: "", + }); + } + + // Alias each contributor ORCID + contributors.forEach((contributor) => { + if (!contributor.orcid) return; + addAliases({ + id: `https://orcid.org/${contributor.orcid}`, + label: contributor.name, + prefix: "", + }); + }); + + // Add source if it exists (replacing the old requireSource check) + if (source) { + const provenanceGraph = rdf["@graph"]["np:hasProvenance"]["@graph"]; + provenanceGraph.push({ + "@id": "#assertion", + "prov:wasDerivedFrom": { "@id": source }, + }); + } + + // Add additional information + pubInfoGraph.push( + { "@id": "#", "@type": "kpxl:DiscourseGraphNanopub" }, + { "@id": "#", "npx:introduces": { "@id": `#${discourseNode?.text}` } } + ); + + // Alias discourse node type + if (nanopubConfig?.nodeType) { + pubInfoGraph.push({ + "@id": nanopubConfig.nodeType, + "rdfs:label": discourseNode?.text, + }); + } + + // TEMP add exampleNanopub + if (isExample) { + pubInfoGraph.push({ "@id": "#", "@type": "npx:ExampleNanopub" }); + } + + return JSON.stringify(rdf, null, 2); + }; + + // DEV + const [rdfOutput, setRdfOutput] = useState(""); + const [checkedOutput, setCheckedOutput] = useState(""); + const [signedOutput, setSignedOutput] = useState(""); + const [publishedOutput, setPublishedOutput] = useState(""); + const [keyPair, setKeyPair] = useState(null); + const [addDevUrl, setAddDevUrl] = useState(""); + + // DEV + const isDev = getNodeEnv() === "development"; + const generateKeyPair = () => { + const keypair = new KeyPair().toJs(); + setKeyPair(keypair); + console.log(keypair); + }; + const checkNanopub = () => { + const np = new Nanopub(rdfString); + const checked = np.check(); + console.log("Checked info dict:", checked.info()); + console.log(checked); + setCheckedOutput(JSON.stringify(checked.info(), null, 2)); + }; + const signNanopub = () => { + const np = new Nanopub(rdfString); + console.log(np); + try { + console.log("signNanopub"); + console.log(PRIVATE_KEY); + const orcid = getCurrentUserOrcid(); + const name = getCurrentUserDisplayName(); + console.log(orcid); + console.log(name); + const profile = new NpProfile(PRIVATE_KEY, orcid, name, ""); + console.log(profile); + const signed = np.sign(profile); + console.log("Signed info dict:", signed.info()); + console.log(signed); + setSignedOutput(JSON.stringify(signed.info(), null, 2)); + } catch (error) { + console.error("Error signing nanopub:", error); + } + }; + const DevDetails = () => { + return ( +
+
+

+ Key Pair:{" "} + {keyPair + ? `Public-${keyPair.public.length} Private-${keyPair.private.length}` + : "No output"} +

+

+ Checked Output: {checkedOutput ? checkedOutput.length : "No output"} +

+

+ Signed Output: {signedOutput ? signedOutput.length : "No output"} +

+

+ Published Output:{" "} + {publishedOutput ? publishedOutput.length : "No output"} +

+

RDF Output: {rdfOutput ? rdfOutput.length : "No output"}

+

+ Published URL:{" "} + {publishedURL ? ( + + Link + + ) : ( + "No URL" + )} +

+
+
+ + + + + + +
+ + setAddDevUrl(e.target.value)} + className="mb-4" + /> + + +
+ ); + }; + // END DEV + + const orcid = getCurrentUserOrcid(); + const orcidRegex = /^https:\/\/orcid\.org\/\d{4}-\d{4}-\d{4}-\d{4}$/; + const orcidUrl = `https://orcid.org/${orcid}`; + + const publishNanopub = async ({ + isDev = "", + isExample = false, + }: { + isDev?: string; + isExample?: boolean; + }) => { + if (!orcidUrl) { + setError( + "ORCID is required. Please set your ORCID in the main settings." + ); + return; + } + if (!orcidRegex.test(orcidUrl)) { + setError("ORCID must be in the format 0000-0000-0000-0000"); + return; + } + if (nanopubConfig?.requireContributors && contributors.length === 0) { + setError( + "This template requires contributors. Please add contributors to the nanopub." + ); + return; + } + if (nanopubConfig?.requireSource && !source) { + setError( + "This template requires a source. Please add a source to the nanopub." + ); + return; + } + const rdfString = await generateRdfString({ + triples: templateTriples || [], + isExample, // TEMP + }); + const serverUrl = isDev ? "" : getNpServer(false); + const currentUser = getCurrentUserDisplayName(); + const profile = new NpProfile(PRIVATE_KEY, orcidUrl, currentUser, ""); + const np = new Nanopub(rdfString); + try { + const published = await np.publish(profile, serverUrl); + const url = published.info().published; + console.log("Published info dict:", published.info()); + setPublishedOutput(JSON.stringify(published.info(), null, 2)); + setRdfOutput(published.rdf()); + setPublishedURL(url); + const props = getBlockProps(uid) as Record; + const nanopub = props["nanopub"] as NanopubPage; + window.roamAlphaAPI.updateBlock({ + block: { + uid, + props: { + ...props, + nanopub: { ...nanopub, published: url }, + }, + }, + }); + setSelectedTabId("nanopub-details"); + } catch (e) { + const error = e as Error; + console.error("Error publishing the Nanopub:", error); + setPublishedOutput(JSON.stringify({ error: error.message }, null, 2)); + apiPost({ + domain: "https://api.samepage.network", + path: "errors", + data: { + method: "extension-error", + type: "Nanopub Publish Failed", + message: error.message, + stack: error.stack, + version: process.env.VERSION, + data: { + templateTriples, + contributors, + rdfString, + orcidUrl, + }, + notebookUuid: JSON.stringify({ + owner: "RoamJS", + app: "query-builder", + workspace: window.roamAlphaAPI.graph.name, + }), + }, + }).catch(() => {}); + } + }; + + const generateAndSetRDF = useCallback(async () => { + const rdfString = await generateRdfString({ + triples: templateTriples || [], + }); + setRdfString(rdfString); + }, [templateTriples]); + + // Tabs + const NanopubDetails = () => { + return ( +
+
+ + + {publishedURL ? ( + Published + ) : ( + Not Published + )} + +
+
+ + + {publishedURL ? ( + + {publishedURL} + + ) : ( + "N/A" + )} + +
+
+ ); + }; + const NanopubTemplate = ({ + node, + handleClose, + }: { + node: string; + handleClose: () => void; + }) => { + const uniqueTypes = Array.from( + new Set(templateTriples?.map((triple) => triple.type) || []) + ); + + return ( + <> +
+ {uniqueTypes.map((type) => ( +
+

{type}

+ {templateTriples + ?.filter((triple) => triple.type === type) + .map((triple) => ( + + ))} +
+ ))} +
+
+
+ + ); + }; + + const TripleString = () => { + return ( +
+