diff --git a/apps/roam/src/utils/canonicalRoamUrl.ts b/apps/roam/src/utils/canonicalRoamUrl.ts new file mode 100644 index 000000000..bb73c860c --- /dev/null +++ b/apps/roam/src/utils/canonicalRoamUrl.ts @@ -0,0 +1,4 @@ +const ROAM_URL_PREFIX = "https://roamresearch.com/#/app/"; +const canonicalRoamUrl = (graphName = window.roamAlphaAPI.graph.name) => + ROAM_URL_PREFIX + graphName; +export default canonicalRoamUrl; diff --git a/apps/roam/src/utils/getExportTypes.ts b/apps/roam/src/utils/getExportTypes.ts index 8670efc70..80ef2e676 100644 --- a/apps/roam/src/utils/getExportTypes.ts +++ b/apps/roam/src/utils/getExportTypes.ts @@ -12,6 +12,7 @@ import { getRelationDataUtil } from "./getRelationData"; import { ExportTypes } from "./types"; import { getExportSettings } from "./getExportSettings"; import { pageToMarkdown, toMarkdown } from "./pageToMarkdown"; +import { getJsonLdData } from "./jsonld"; import { uniqJsonArray, getPageData, @@ -144,6 +145,29 @@ const getExportTypes = ({ ]; }, }, + { + name: "JSON-LD (Experimental)", + callback: async ({ filename }) => { + if (!results) return []; + const data = await getJsonLdData({ + results, + allNodes, + allRelations, + nodeLabelByType, + updateExportProgress: async (progress: number) => { + updateExportProgress({ progress, id: exportId }); + // skip a beat to let progress render + await new Promise((resolve) => setTimeout(resolve)); + }, + }); + return [ + { + title: `${filename.replace(/\.json$/, "")}.json`, + content: JSON.stringify(data, undefined, " "), + }, + ]; + }, + }, { name: "Neo4j", callback: async ({ filename }) => { diff --git a/apps/roam/src/utils/getPageMetadata.ts b/apps/roam/src/utils/getPageMetadata.ts index e44416321..f47c767b6 100644 --- a/apps/roam/src/utils/getPageMetadata.ts +++ b/apps/roam/src/utils/getPageMetadata.ts @@ -14,21 +14,25 @@ const getDisplayName = (s: string) => { const getPageMetadata = (title: string, cacheKey?: string) => { const results = window.roamAlphaAPI.q( - `[:find (pull ?p [:create/time :block/uid]) (pull ?cu [:user/uid]) :where [?p :node/title "${normalizePageTitle( + `[:find (pull ?p [:block/uid :create/time [:edit/time :as "modified"]]) (pull ?cu [:user/uid]) :where [?p :node/title "${normalizePageTitle( title, )}"] [?p :create/user ?cu]]`, - ) as [[{ time: number; uid: string }, { uid: string }]]; + ) as [[{ uid: string; time: number; modified: number }, { uid: string }]]; if (results.length) { - const [[{ time: createdTime, uid: id }, { uid }]] = results; + const [[{ uid: id, time: createdTime, modified: modifiedTime }, { uid }]] = + results; const displayName = getDisplayName(uid); const date = new Date(createdTime); - return { displayName, date, id }; + const modified = new Date(modifiedTime); + return { displayName, date, id, modified }; } + const date = new Date(); return { displayName: "Unknown", - date: new Date(), + date, id: "", + modified: date, }; }; diff --git a/apps/roam/src/utils/jsonld.ts b/apps/roam/src/utils/jsonld.ts new file mode 100644 index 000000000..9ce326880 --- /dev/null +++ b/apps/roam/src/utils/jsonld.ts @@ -0,0 +1,188 @@ +import type { Result } from "roamjs-components/types/query-builder"; +import type { DiscourseRelation } from "./getDiscourseRelations"; +import type { DiscourseNode } from "./getDiscourseNodes"; +import getPageMetadata from "./getPageMetadata"; +import { pageToMarkdown } from "./pageToMarkdown"; +import { getRelationDataUtil } from "./getRelationData"; +import { uniqJsonArray, getPageData } from "./exportUtils"; +import { getExportSettings } from "./getExportSettings"; +import canonicalRoamUrl from "./canonicalRoamUrl"; +import internalError from "./internalError"; + +export const jsonLdContext = (baseUrl: string): Record => ({ + rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + rdfs: "http://www.w3.org/2000/01/rdf-schema#", + owl: "http://www.w3.org/2002/07/owl#", + dc: "http://purl.org/dc/elements/1.1/", + prov: "http://www.w3.org/ns/prov#", + sioc: "http://rdfs.org/sioc/ns#", + dgb: "https://discoursegraphs.com/schema/dg_base", + subClassOf: "rdfs:subClassOf", + title: "dc:title", + label: "rdfs:label", + modified: "dc:modified", + created: "dc:date", + creator: "dc:creator", + content: "sioc:content", + source: "dgb:source", + destination: "dgb:destination", + textRefersToNode: "dgb:textRefersToNode", + predicate: "rdf:predicate", + nodeSchema: "dgb:NodeSchema", + relationDef: "dgb:RelationDef", + relationInstance: "dgb:RelationInstance", + inverseOf: "owl:inverseOf", + pages: `${baseUrl}/page/`, +}); + +export const getJsonLdSchema = async ({ + allNodes, + allRelations, + updateExportProgress, +}: { + allNodes: DiscourseNode[]; + allRelations: DiscourseRelation[]; + updateExportProgress: (progress: number) => Promise; +}): Promise[]> => { + let numTreatedPages = 0; + const settings = { + ...getExportSettings(), + includeDiscourseContext: false, + }; + // TODO : Identify existing CURIES in the node definition + const nodeSchemaData = await Promise.all( + allNodes.map(async (node: DiscourseNode) => { + const { date, displayName, modified } = getPageMetadata(node.text); + const r = await pageToMarkdown( + { + text: node.text, + uid: node.type, + }, + { ...settings, allNodes }, + ); + numTreatedPages += 1; + await updateExportProgress(numTreatedPages); + return { + "@id": `pages:${node.type}`, // eslint-disable-line @typescript-eslint/naming-convention + "@type": "nodeSchema", // eslint-disable-line @typescript-eslint/naming-convention + label: node.text, + content: r.content, + modified: modified?.toJSON(), + created: date.toJSON(), + creator: displayName, + }; + }), + ); + const relSchemaData = allRelations.map((r: DiscourseRelation) => ({ + "@id": `pages:${r.id}`, // eslint-disable-line @typescript-eslint/naming-convention + "@type": "relationDef", // eslint-disable-line @typescript-eslint/naming-convention + domain: `pages:${r.source}`, + range: `pages:${r.destination}`, + label: r.label, + })); + const inverseRelSchemaData = allRelations.map((r: DiscourseRelation) => ({ + "@id": `pages:${r.id}-inverse`, // eslint-disable-line @typescript-eslint/naming-convention + "@type": "relationDef", // eslint-disable-line @typescript-eslint/naming-convention + domain: `pages:${r.destination}`, + range: `pages:${r.source}`, + label: r.complement, + inverseOf: `pages:${r.id}`, + })); + /* eslint-enable @typescript-eslint/naming-convention */ + return [...nodeSchemaData, ...relSchemaData, ...inverseRelSchemaData]; +}; + +export const getJsonLdData = async ({ + results, + allNodes, + allRelations, + nodeLabelByType, + updateExportProgress, +}: { + results: Result[]; + allNodes: DiscourseNode[]; + allRelations: DiscourseRelation[]; + nodeLabelByType: Record; + updateExportProgress: (progress: number) => Promise; +}): Promise< + Record | Record[]> +> => { + const roamUrl = canonicalRoamUrl(); + const getRelationData = () => + getRelationDataUtil({ allRelations, nodeLabelByType }); + await updateExportProgress(0); + const pageData = getPageData({ results, allNodes }); + const numPages = pageData.length + allNodes.length; + let numTreatedPages = 0; + const settings = { + ...getExportSettings(), + includeDiscourseContext: false, + }; + const schemaData = await getJsonLdSchema({ + allNodes, + allRelations, + updateExportProgress: async (numTreatedPages: number) => { + await updateExportProgress(0.1 + (numTreatedPages / numPages) * 0.75); + }, + }); + + const nodeSchemaUriByName = Object.fromEntries( + schemaData + .filter((s) => s.content !== undefined) + .map((node) => [node.label, node["@id"]]), + ); + + await Promise.all( + pageData.map(async (page: Result) => { + const r = await pageToMarkdown(page, { + ...settings, + allNodes, + linkType: "roam url", + }); + page.content = r.content; + numTreatedPages += 1; + await updateExportProgress(0.1 + (numTreatedPages / numPages) * 0.75); + }), + ); + + const nodes = pageData.map(({ text, uid, content, type }) => { + const { date, displayName, modified } = getPageMetadata(text); + const nodeType = nodeSchemaUriByName[type]; + if (!nodeType) { + internalError({ + error: `Unknown node type "${type}" for page "${text}"`, + }); + } + const r = { + "@id": `pages:${uid}`, // eslint-disable-line @typescript-eslint/naming-convention + "@type": nodeType ?? "nodeSchema", // eslint-disable-line @typescript-eslint/naming-convention + title: text, + content: content as string, + modified: modified?.toJSON(), + created: date.toJSON(), + creator: displayName, + }; + return r; + }); + const nodeSet = new Set(pageData.map((n) => n.uid)); + const rels = await getRelationData(); + await updateExportProgress(1); + const relations = uniqJsonArray( + rels.filter((r) => nodeSet.has(r.source) && nodeSet.has(r.target)), + ); + const relData = relations.map(({ relUid, source, target }) => ({ + // no id yet, just a blank node + "@type": "relationInstance", // eslint-disable-line @typescript-eslint/naming-convention + predicate: `pages:${relUid}`, + source: `pages:${source}`, + destination: `pages:${target}`, + })); + return { + /* eslint-disable @typescript-eslint/naming-convention */ + "@context": jsonLdContext(roamUrl), + "@id": roamUrl, + "prov:generatedAtTime": new Date().toISOString(), + "@graph": [...schemaData, ...nodes, ...relData], + /* eslint-enable @typescript-eslint/naming-convention */ + }; +}; diff --git a/apps/roam/src/utils/supabaseContext.ts b/apps/roam/src/utils/supabaseContext.ts index 6d09e0366..315ff7746 100644 --- a/apps/roam/src/utils/supabaseContext.ts +++ b/apps/roam/src/utils/supabaseContext.ts @@ -1,8 +1,8 @@ import getCurrentUserEmail from "roamjs-components/queries/getCurrentUserEmail"; import getCurrentUserDisplayName from "roamjs-components/queries/getCurrentUserDisplayName"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; -import getRoamUrl from "roamjs-components/dom/getRoamUrl"; +import canonicalRoamUrl from "./canonicalRoamUrl"; import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/utils/renderNodeConfigPage"; import getBlockProps from "~/utils/getBlockProps"; import setBlockProps from "~/utils/setBlockProps"; @@ -27,8 +27,6 @@ export type SupabaseContext = { let _contextCache: SupabaseContext | null = null; -const ROAM_URL_PREFIX = "https://roamresearch.com/#/app/"; - const getOrCreateSpacePassword = () => { const settingsConfigPageUid = getPageUidByPageTitle( DISCOURSE_CONFIG_PAGE_TITLE, @@ -57,7 +55,7 @@ export const getSupabaseContext = async (): Promise => { const personEmail = getCurrentUserEmail(); const personName = getCurrentUserDisplayName(); const spaceName = window.roamAlphaAPI.graph.name; - const url = ROAM_URL_PREFIX + spaceName; + const url = canonicalRoamUrl(spaceName); const platform: Platform = "Roam"; const spaceResult = await fetchOrCreateSpaceDirect({ password: spacePassword, diff --git a/apps/website/public/schema/dg_base.ttl b/apps/website/public/schema/dg_base.ttl new file mode 100644 index 000000000..cdbf659fa --- /dev/null +++ b/apps/website/public/schema/dg_base.ttl @@ -0,0 +1,87 @@ +@prefix rdf: . +@prefix rdfs: . +@prefix : . +@prefix dc: . +@prefix owl: . +@prefix dgb: . + + + dc:date "2025-12-22" ; + rdfs:comment "DiscourseGraph foundation vocabulary"@en ; + rdfs:label "DiscourseGraph foundation vocabulary"@en ; + owl:versionInfo "0 (tentative)" ; + a owl:Ontology. + +# This is inspired by https://hyperknowledge.org/schemas/hyperknowledge_frames.ttl +# and topic mapping + +dgb:NodeSchema +rdfs:subClassOf owl:Class; + rdfs:comment "Subclasses of DiscourseGraph nodes"@en . + +dgb:Role + rdfs:subClassOf owl:ObjectProperty, + [a owl:Restriction; owl:onProperty rdfs:domain ; owl:allValuesFrom dgb:NodeSchema ], + [a owl:Restriction; owl:onProperty rdfs:range ; owl:allValuesFrom dgb:NodeSchema ]; + rdfs:comment "A role within a node schema"@en . + +dgb:RelationDef rdfs:subClassOf owl:ObjectProperty; + rdfs:comment "DiscourseGraph relations"@en. + +dgb:RelationInstance rdfs:subClassOf rdf:Statement, dgb:NodeSchema, + [a owl:Restriction; owl:onProperty rdfs:predicate ; owl:allValuesFrom dgb:RelationDef ]. + +dgb:source a dgb:Role ; + rdfs:subPropertyOf rdf:subject ; + rdfs:domain dgb:RelationInstance ; + rdfs:range dgb:NodeSchema ; + rdfs:comment "The source of a binary relation"@en . + +dgb:destination a dgb:Role ; + rdfs:subPropertyOf rdf:object ; + rdfs:domain dgb:RelationInstance ; + rdfs:range dgb:NodeSchema ; + rdfs:comment "The destination of a binary relation"@en . + +dgb:textRefersToNode a owl:ObjectProperty; + rdfs:domain dgb:NodeSchema; + rdfs:range dgb:NodeSchema; + rdfs:comment "The text of a node refers to another node"@en . + + +# examples + +# :x a dgb:NodeSchema . +# :y a dgb:NodeSchema . +# :x0 a :x. +# :y0 a :y. +# :r a dgb:NodeSchema. +# :x_r a dgb:Role ; +# rdfs:domain :r ; +# rdfs:range :x . + +# :r0 a :r; +# :x_r :x0. + +# :br a dgb:RelationDef; +# rdfs:domain :x; +# rdfs:range :y; + +# :br0 +# a dgb:RelationInstance; +# rdf:predicate :br ; +# dgb:source :x0 ; +# dgb:destination :y0 ; + +# # This is "about" :x0 :br :y0; + +# Note: we could also use punning, and define +# :br rdfs:subClassOf dgb:RelationInstance, +# [a owl:Restriction; +# owl:onProperty rdf:predicate ; +# owl:hasValue :br ]. +# Then we can more simply state +# :br0 +# a :br; +# dgb:source :x0 ; +# dgb:destination :y0 ; diff --git a/apps/website/public/schema/dg_core.ttl b/apps/website/public/schema/dg_core.ttl new file mode 100644 index 000000000..0a12121a7 --- /dev/null +++ b/apps/website/public/schema/dg_core.ttl @@ -0,0 +1,92 @@ +@prefix rdf: . +@prefix rdfs: . +@prefix : . +@prefix dc: . +@prefix owl: . +@prefix vs: . +@prefix sioc: . +@prefix prov: . +@prefix dgb: . +@prefix dg: . + +dg:Question a dgb:NodeSchema; + rdfs:label "Question"@en; + rdfs:comment "Scientific unknowns that we want to make known, and are addressable by the systematic application of research methods"@en. + +dg:Claim a dgb:NodeSchema; + rdfs:label "Claim"@en; + rdfs:comment "Atomic, generalized assertions about the world that (propose to) answer research questions"@en. + +dg:Evidence a dgb:NodeSchema; + rdfs:label "Evidence"@en; + rdfs:comment "A specific empirical observation from a particular application of a research method"@en. + +dg:Source a dgb:NodeSchema; + rdfs:label "Source"@en; + rdfs:comment "Some research source that reports/generates evidence, like an experiment/study, book, conference paper, or journal article"@en. + +dg:opposesCE a dgb:RelationDef; + rdfs:label "Opposes"@en; + rdfs:range dg:Claim; + rdfs:domain dg:Evidence. + +dg:opposedByEC a dgb:RelationDef; + rdfs:label "Opposed by"@en; + owl:inverseOf dg:opposesCE; + rdfs:range dg:Evidence; + rdfs:domain dg:Claim. + +dg:supportsCE a dgb:RelationDef; + rdfs:label "Supports"@en; + rdfs:range dg:Claim; + rdfs:domain dg:Evidence. + +dg:supportedByEC a dgb:RelationDef; + rdfs:label "Supported by"@en; + owl:inverseOf dg:supportsCE; + rdfs:range dg:Evidence; + rdfs:domain dg:Claim. + +dg:opposesCC a dgb:RelationDef; + rdfs:label "Opposes"@en; + rdfs:range dg:Claim; + rdfs:domain dg:Claim. + +dg:opposedByCC a dgb:RelationDef; + rdfs:label "Opposed by"@en; + owl:inverseOf dg:opposesCC; + rdfs:range dg:Claim; + rdfs:domain dg:Claim. + +dg:supportsCC a dgb:RelationDef; + rdfs:label "Supports"@en; + rdfs:range dg:Claim; + rdfs:domain dg:Claim. + +dg:supportedByCC a dgb:RelationDef; + rdfs:label "Supported by"@en; + owl:inverseOf dg:supportsCC; + rdfs:range dg:Claim; + rdfs:domain dg:Claim. + +dg:addresses a dgb:RelationDef; + rdfs:label "Addresses"@en; + rdfs:range dg:Question; + rdfs:domain dg:Claim. + +dg:addressedBy a dgb:RelationDef; + rdfs:label "Addressed by"@en; + owl:inverseOf dg:addresses; + rdfs:range dg:Claim; + rdfs:domain dg:Question. + +dg:curatedTo a dgb:RelationDef; + rdfs:label "Curated to"@en; + rdfs:range dg:Source; + rdfs:domain dg:Evidence. + +dg:curatedFrom a dgb:RelationDef; + owl:inverseOf dg:curatedTo; + rdfs:label "Curated from"@en; + rdfs:range dg:Evidence; + rdfs:domain dg:Source.