diff --git a/apps/roam/package.json b/apps/roam/package.json index 95e1829e9..037cdcaae 100644 --- a/apps/roam/package.json +++ b/apps/roam/package.json @@ -37,6 +37,8 @@ "@blueprintjs/core": "3.50.4", "@blueprintjs/icons": "3.30.2", "@blueprintjs/select": "3.19.1", + "@nanopub/nanopub-js": "^0.1.0", + "@nanopub/sign": "0.1.10", "@octokit/auth-app": "^7.1.4", "@octokit/core": "^6.1.3", "@repo/database": "workspace:*", diff --git a/apps/roam/src/components/nanopub/ContributorManager.tsx b/apps/roam/src/components/nanopub/ContributorManager.tsx new file mode 100644 index 000000000..1889ac24f --- /dev/null +++ b/apps/roam/src/components/nanopub/ContributorManager.tsx @@ -0,0 +1,326 @@ +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: string, contributorParam: Contributor, remove = false) => { + setContributors((_contributors) => + _contributors.map((c) => + contributorParam.id === c.id + ? { + ...c, + roles: remove + ? c.roles?.filter((r) => r !== v) + : [...(c.roles || []), v], + } + : c, + ), + ); + }, + [setContributors], + ); + + 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 ( +
+ ); + }, +); + +ContributorRow.displayName = "ContributorRow"; + +export default ContributorManager; diff --git a/apps/roam/src/components/nanopub/ExportNanopub.tsx b/apps/roam/src/components/nanopub/ExportNanopub.tsx new file mode 100644 index 000000000..8dbae8e6c --- /dev/null +++ b/apps/roam/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({ uid: r.uid }); + 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/apps/roam/src/components/nanopub/Nanopub.tsx b/apps/roam/src/components/nanopub/Nanopub.tsx new file mode 100644 index 000000000..278033358 --- /dev/null +++ b/apps/roam/src/components/nanopub/Nanopub.tsx @@ -0,0 +1,929 @@ +import React, { useEffect, useState, useMemo, useCallback } from "react"; +import { + Dialog, + Button, + TextArea, + Tabs, + Tab, + TabId, + Label, + Tag, + Text, + FormGroup, + InputGroup, + Classes, +} from "@blueprintjs/core"; +import { + initNanopubSignWasm, + NANOPUB_REGISTRY_URLS, + sign, + TEST_NANOPUB_REGISTRY_URL, + verifySignature, +} from "@nanopub/nanopub-js"; +import renderOverlay from "roamjs-components/util/renderOverlay"; +import { OnloadArgs } from "roamjs-components/types"; +import findDiscourseNode from "~/utils/findDiscourseNode"; +import getDiscourseNodes from "~/utils/getDiscourseNodes"; +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 from "~/utils/getExportTypes"; +import { DiscourseNode, NanopubConfig } from "~/utils/getDiscourseNodes"; +import { getNodeEnv } from "roamjs-components/util/env"; +import runQuery from "~/utils/runQuery"; +import PreviewNanopub from "./PreviewNanopub"; +import SourceManager from "./SourceManager"; +import extractContentFromTitle from "~/utils/extractContentFromTitle"; +import matchDiscourseNode from "~/utils/matchDiscourseNode"; +import internalError from "~/utils/internalError"; + +export type NanopubPage = { + contributors: Contributor[]; + source?: string; + published?: string; +}; +export type Contributor = { + id: string; + name: string; + orcid: string; + roles: string[]; +}; + +// Helper function to extract content from format (similar to query-builder) +const extractContentFromFormat = ({ + title, + allNodes = getDiscourseNodes(), +}: { + title: string; + allNodes?: DiscourseNode[]; +}) => { + const node = allNodes.find((a) => matchDiscourseNode({ title, ...a })); + if (!node) return title; + return extractContentFromTitle(title, node); +}; + +// 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; +}) => ( +
+ + {subject} + + + {predicate} + + + {object} + +
+); + +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({ + includeDiscourseContext: false, + filename: "", + isExportDiscourseGraph: false, + }); + 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; + } + + 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 = findDiscourseNode({ uid }); + setDiscourseNode(node || null); + }, [uid]); + 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 = async () => { + try { + const isValid = await verifySignature(rdfString); + const checked = { + validSignature: isValid, + }; + console.log("Checked info dict:", checked); + setCheckedOutput(JSON.stringify(checked, null, 2)); + } catch (error) { + console.error("Error checking nanopub:", error); + const message = + error instanceof Error ? error.message : "Failed to check nanopub"; + setCheckedOutput(JSON.stringify({ error: message }, null, 2)); + } + }; + const signNanopub = async () => { + try { + console.log("signNanopub"); + console.log(PRIVATE_KEY); + const name = getCurrentUserDisplayName(); + console.log(orcidUrl); + console.log(name); + const signed = await sign(rdfString, PRIVATE_KEY, orcidUrl, name); + console.log("Signed info dict:", signed); + setSignedOutput(JSON.stringify(signed, 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"} */} + {/* {keyPair?.toJs()} */} +

+

+ 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 ({ + useTestServer = isDev, + isExample = false, + }: { + useTestServer?: boolean; + 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 = useTestServer + ? TEST_NANOPUB_REGISTRY_URL + : NANOPUB_REGISTRY_URLS[1] || NANOPUB_REGISTRY_URLS[0]; + const currentUser = getCurrentUserDisplayName(); + try { + const signed = await sign(rdfString, PRIVATE_KEY, orcidUrl, currentUser); + const publishResponse = await fetch(serverUrl, { + method: "POST", + headers: { + "Content-Type": "application/trig", + }, + body: signed.signedRdf, + }); + if (!publishResponse.ok) { + const errorBody = await publishResponse.text().catch(() => ""); + throw new Error( + `Nanopub publish failed: ${publishResponse.status} ${errorBody || publishResponse.statusText}`, + ); + } + const url = + signed.sourceUri || + publishResponse.headers.get("Location") || + publishResponse.headers.get("location") || + ""; + const publishedInfo = { + uri: url, + server: serverUrl, + status: publishResponse.status, + statusText: publishResponse.statusText, + signature: signed.signature, + }; + console.log("Published info dict:", publishedInfo); + setPublishedOutput(JSON.stringify(publishedInfo, null, 2)); + setRdfOutput(signed.signedRdf); + 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)); + internalError({ + error, + type: "Nanopub Publish Failed", + context: { + templateTriples, + contributors, + rdfString, + orcidUrl, + }, + }); + } + }; + + 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 ( +
+