diff --git a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx index fb87c6859..cb299caa1 100644 --- a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx +++ b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx @@ -21,6 +21,7 @@ import { isFailedSharedNodeImport, type SharedNodeImportItem, } from "~/utils/importSharedNodes"; +import { importSharedRelations } from "~/utils/importSharedRelations"; import internalError from "~/utils/internalError"; import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext"; @@ -146,6 +147,7 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => { const [error, setError] = useState(""); const [searchTerm, setSearchTerm] = useState(""); const [selectedRids, setSelectedRids] = useState>(new Set()); + const [spaceId, setSpaceId] = useState(0); const [importProgress, setImportProgress] = useState<{ current: number; total: number; @@ -163,6 +165,7 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => { try { const context = await getSupabaseContext(); if (!context) throw new Error("Could not connect to shared persistence."); + setSpaceId(context.spaceId); const client = await getLoggedInClient(); if (!client) throw new Error("Could not connect to shared persistence."); const { sharedNodes, importedSourceRids } = await discoverSharedNodes({ @@ -242,7 +245,6 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => { sharedNodes: selectedNodes, onProgress: (current, total) => setImportProgress({ current, total }), }); - setImportResults(results); const newlyImportedRids = results .filter((item) => item.status !== "failed") .map((item) => item.sharedNode.rid); @@ -251,6 +253,8 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => { newlyImportedRids.forEach((rid) => next.add(rid)); return next; }); + await importSharedRelations(client, spaceId, [...importedRids]); + setImportResults(results); const failedImports = results.filter(isFailedSharedNodeImport); setSelectedRids( new Set(failedImports.map((item) => item.sharedNode.rid)), diff --git a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx index 0f29581e7..e543966c3 100644 --- a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx @@ -59,28 +59,12 @@ const DiscourseNodeConfigPanel: React.FC = ({ } }; - const getUnusedShortcut = (): string => { - const candidateShortcut = label.slice(0, 1).toUpperCase(); - const existingShortcuts = new Set( - getDiscourseNodes() - .map((n) => n.shortcut.toUpperCase()) - .filter(Boolean), - ); - return existingShortcuts.has(candidateShortcut) ? "" : candidateShortcut; - }; - const createNodeType = async (): Promise => { setIsCreating(true); try { - const shortcut = getUnusedShortcut(); - const format = `[[${label.slice(0, 3).toUpperCase()}]] - {content}`; posthog.capture("Discourse Node: Type Created", { label }); - const node = await createDiscourseNodeType({ - text: label, - shortcut, - format, - }); + const node = await createDiscourseNodeType({ label }); setNodes((prevNodes) => [...prevNodes, node]); refreshConfigTree(); diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index 20250daff..8c0ce7f1e 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -10,12 +10,15 @@ import { getSubTree } from "roamjs-components/util"; import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; import internalError from "~/utils/internalError"; import { getSetting } from "~/utils/extensionSettings"; +import { getRoamMarkdownApi } from "~/utils/materializeSharedNode"; import type { RoamBasicNode } from "roamjs-components/types"; import discourseConfigRef from "~/utils/discourseConfigRef"; import { roamNodeToCondition } from "~/utils/parseQuery"; import type { DiscourseRelation } from "~/utils/getDiscourseRelations"; -import type { DiscourseNode } from "~/utils/getDiscourseNodes"; +import getDiscourseNodes, { + type DiscourseNode, +} from "~/utils/getDiscourseNodes"; import type { Condition } from "~/utils/types"; import { z } from "zod"; import { @@ -266,7 +269,6 @@ const getLegacyPersonalLeftSidebarSetting = (): unknown[] => { "Result-limit": section.settings?.resultLimit?.value ?? 0, }, })); - /* eslint-enable @typescript-eslint/naming-convention */ }; const getLegacyPersonalSetting = (keys: string[]): unknown => { @@ -533,7 +535,7 @@ const getLegacyDiscourseNodeSetting = ( "key-image-option": rawCanvas["key-image-option"] || "first-image", "query-builder-alias": rawCanvas["query-builder-alias"] || "", }; - /* eslint-enable @typescript-eslint/naming-convention */ + const attributes = Object.fromEntries( getSubTree({ tree, key: "Attributes" }).children.map((c) => [ c.text, @@ -717,7 +719,6 @@ const FEATURE_FLAG_LEGACY_MAP: Record< text: "(BETA) Left Sidebar", }).value, }; -/* eslint-enable @typescript-eslint/naming-convention */ export const getFeatureFlag = (key: keyof FeatureFlags): boolean => { return bulkReadSettings().featureFlags[key]; @@ -954,7 +955,7 @@ const getRawDiscourseNodeBlockProps = ( } return isRecord(blockProps) && Object.keys(blockProps).length > 0 - ? (blockProps as Record) + ? blockProps : undefined; }; @@ -1058,7 +1059,7 @@ const addConditionUids = (conditions: SchemaCondition[]): Condition[] => target: c.target, not: c.not, }; - }) as Condition[]; + }); const toDiscourseNode = (settings: DiscourseNodeSettings): DiscourseNode => ({ text: settings.text, @@ -1085,34 +1086,74 @@ const toDiscourseNode = (settings: DiscourseNodeSettings): DiscourseNode => ({ : undefined, }); +const getUnusedShortcut = (label: string): string => { + const candidateShortcut = label.slice(0, 1).toUpperCase(); + const existingShortcuts = new Set( + getDiscourseNodes() + .map((n) => n.shortcut.toUpperCase()) + .filter(Boolean), + ); + return existingShortcuts.has(candidateShortcut) ? "" : candidateShortcut; +}; + // getAllDiscourseNodes skips prop-less pages, so invalidate only after the props write settles. export const createDiscourseNodeType = async ({ - text, + label, shortcut, format, + template, }: { - text: string; - shortcut: string; - format: string; + label: string; + shortcut?: string; + format?: string; + template?: string; }): Promise => { + if (shortcut === undefined) shortcut = getUnusedShortcut(label); + format = format ?? `[[${label.slice(0, 3).toUpperCase()}]] - {content}`; + const tree = [ + { + text: "Shortcut", + children: [{ text: shortcut }], + }, + { + text: "Tag", + children: [{ text: "" }], + }, + { + text: "Format", + children: [{ text: format }], + }, + ]; + if (template !== undefined) { + tree.push({ + text: "Template", + children: [], + }); + } const pageUid = await createPage({ - title: `${DISCOURSE_NODE_PAGE_PREFIX}${text}`, - tree: [ - { text: "Shortcut", children: [{ text: shortcut }] }, - { text: "Tag", children: [{ text: "" }] }, - { text: "Format", children: [{ text: format }] }, - ], + title: `discourse-graph/nodes/${label}`, + tree, }); - + let templateTree: RoamBasicNode[] | undefined; + if (template !== undefined) { + const tree = getBasicTreeByParentUid(pageUid); + const templateUid = tree[3].uid; + await getRoamMarkdownApi().block.fromMarkdown({ + location: { "parent-uid": templateUid, order: "last" }, + "markdown-string": template, + }); + templateTree = getBasicTreeByParentUid(templateUid); + } const settings = DiscourseNodeSchema.parse({ - text, + text: label, type: pageUid, shortcut, format, + template: templateTree, }); + setBlockProps(pageUid, settings); await setBlockPropsAsync(pageUid, settings); invalidateDiscourseNodeTypeCaches(); - return toDiscourseNode(settings); }; @@ -1224,9 +1265,7 @@ export const getAllDiscourseNodes = (): DiscourseNode[] => { ); } else { // Try migrating legacy field shapes before dropping the node. - const migrated = migrateNodeBlockProps( - blockProps as Record, - ); + const migrated = migrateNodeBlockProps(blockProps); const retryResult = DiscourseNodeSchema.safeParse(migrated); if (retryResult.success) { setBlockProps(pageUid, retryResult.data, false); diff --git a/apps/roam/src/utils/__tests__/nodeSharingFeatureFlag.test.ts b/apps/roam/src/utils/__tests__/nodeSharingFeatureFlag.test.ts index 5aefa2f0f..eda0a4d79 100644 --- a/apps/roam/src/utils/__tests__/nodeSharingFeatureFlag.test.ts +++ b/apps/roam/src/utils/__tests__/nodeSharingFeatureFlag.test.ts @@ -4,6 +4,13 @@ vi.mock("~/utils/internalError", () => ({ default: vi.fn() })); vi.mock("~/utils/extensionSettings", () => ({ getSetting: vi.fn() })); vi.mock("~/utils/parseQuery", () => ({ roamNodeToCondition: vi.fn() })); +// Runs before the imports below: getDiscourseNodes calls generateUID at module load. +vi.hoisted(() => { + (globalThis as { window?: unknown }).window = { + roamAlphaAPI: { util: { generateUID: () => "someUid" } }, + }; +}); + import { isNodeSharingEnabled, isSyncEnabled, @@ -13,6 +20,7 @@ const seedWindow = (featureFlags: Record) => { (globalThis as { window: unknown }).window = { roamAlphaAPI: { user: { uid: () => "user-1" }, + util: { generateUID: () => "someUid" }, pull: () => ({ ":block/children": [ { diff --git a/apps/roam/src/utils/__tests__/queryParsing.test.ts b/apps/roam/src/utils/__tests__/queryParsing.test.ts index b885f8997..76efe0e35 100644 --- a/apps/roam/src/utils/__tests__/queryParsing.test.ts +++ b/apps/roam/src/utils/__tests__/queryParsing.test.ts @@ -23,6 +23,12 @@ vi.mock("roamjs-components/util/getSettingValueFromTree", () => ({ vi.mock("roamjs-components/writes/createBlock", () => ({ default: vi.fn(), })); +// Runs before the imports below: getDiscourseNodes calls generateUID at module load. +vi.hoisted(() => { + (globalThis as { window?: unknown }).window = { + roamAlphaAPI: { util: { generateUID: () => "someUid" } }, + }; +}); import getSubTree from "roamjs-components/util/getSubTree"; import createBlock from "roamjs-components/writes/createBlock"; diff --git a/apps/roam/src/utils/createReifiedBlock.ts b/apps/roam/src/utils/createReifiedBlock.ts index ad78975f7..c4d044fae 100644 --- a/apps/roam/src/utils/createReifiedBlock.ts +++ b/apps/roam/src/utils/createReifiedBlock.ts @@ -136,17 +136,21 @@ export const createReifiedRelation = async ({ sourceUid, relationBlockUid, destinationUid, + tentative, }: { sourceUid: string; relationBlockUid: string; destinationUid: string; -}): Promise => { + tentative?: boolean; +}): Promise => { + const parameterUids: Record = { + sourceUid, + destinationUid, + ...(tentative !== undefined && { tentative: String(tentative) }), + }; return await createReifiedBlock({ destinationBlockUid: await getOrCreateRelationPageUid(), schemaUid: relationBlockUid, - parameterUids: { - sourceUid, - destinationUid, - }, + parameterUids, }); }; diff --git a/apps/roam/src/utils/createRelationSchema.ts b/apps/roam/src/utils/createRelationSchema.ts new file mode 100644 index 000000000..696f0e7e2 --- /dev/null +++ b/apps/roam/src/utils/createRelationSchema.ts @@ -0,0 +1,53 @@ +import discourseConfigRef from "~/utils/discourseConfigRef"; +import createBlock from "roamjs-components/writes/createBlock"; +import { setGlobalSetting } from "~/components/settings/utils/accessors"; +import { GLOBAL_KEYS } from "~/components/settings/utils/settingKeys"; + +export const createRelationSchema = async ({ + label, + complement, + source, + destination, +}: { + label: string; + complement: string; + source: string; + destination: string; +}) => { + const grammarNode = discourseConfigRef.tree.find( + (node) => node.text === "grammar", + ); + const relationsNode = grammarNode?.children.find( + (node) => node.text === "relations", + ); + if (!relationsNode) throw new Error("Cannot find the relation grammar"); + const blockUid = await createBlock({ + parentUid: relationsNode.uid, + order: "last", + node: { + text: label, + children: [ + { + text: "source", + children: [{ text: source }], + }, + { + text: "destination", + children: [{ text: destination }], + }, + { + text: "complement", + children: [{ text: complement }], + }, + ], + }, + }); + setGlobalSetting([GLOBAL_KEYS.relations, blockUid], { + label, + source, + destination, + complement, + ifConditions: [], + }); + return blockUid; +}; diff --git a/apps/roam/src/utils/getDiscourseRelations.ts b/apps/roam/src/utils/getDiscourseRelations.ts index d7e36cab7..42518fb75 100644 --- a/apps/roam/src/utils/getDiscourseRelations.ts +++ b/apps/roam/src/utils/getDiscourseRelations.ts @@ -12,6 +12,7 @@ import { type SettingsSnapshot, } from "~/components/settings/utils/accessors"; import discourseConfigRef from "./discourseConfigRef"; +import { getStoredRelationsEnabled } from "~/utils/storedRelations"; export type Triple = readonly [string, string, string]; export type DiscourseRelation = { @@ -47,6 +48,7 @@ const getDiscourseRelations = (snapshot?: SettingsSnapshot) => { const grammarNode = getGrammarNode(); const relationsNode = getRelationsNode(grammarNode); const relationNodes = relationsNode?.children || DEFAULT_RELATION_VALUES; + const storedRelationsEnabled = getStoredRelationsEnabled(); const discourseRelations = relationNodes.flatMap( (r: InputTextNode, i: number) => { const tree = (r?.children || []) as TextNode[]; @@ -58,6 +60,9 @@ const getDiscourseRelations = (snapshot?: SettingsSnapshot) => { complement: getSettingValueFromTree({ tree, key: "Complement" }), }; const ifNode = tree.find(matchNodeText("if"))?.children || []; + if (ifNode.length === 0 && storedRelationsEnabled) { + return [{ ...data, triples: [] }]; + } return ifNode.map((node) => ({ ...data, triples: node.children diff --git a/apps/roam/src/utils/importSharedRelations.ts b/apps/roam/src/utils/importSharedRelations.ts new file mode 100644 index 000000000..ee2731aaf --- /dev/null +++ b/apps/roam/src/utils/importSharedRelations.ts @@ -0,0 +1,269 @@ +import type { + CrossAppRelation, + CrossAppRelationTypeSchema, + CrossAppRelationTripleSchema, + CrossAppNodeSchema, +} from "@repo/database/crossAppContracts"; +import { + spaceUriAndLocalIdToRid, + isRid, + ridToSpaceUriAndLocalId, +} from "@repo/database/lib/rid"; +import { + findImportedNodeUidBySourceRid, + getImportedSourceRids, + writeImportedSourceIdentity, +} from "./importedSourceIdentity"; +import getDiscourseRelations from "./getDiscourseRelations"; +import getDiscourseNodes from "./getDiscourseNodes"; +import { createDiscourseNodeType } from "~/components/settings/utils/accessors"; +import { createRelationSchema } from "./createRelationSchema"; +import { + createReifiedRelation, + getReifiedRelations, +} from "./createReifiedBlock"; +import { discoverSharedRelations } from "./discoverSharedRelations"; +import { DGSupabaseClient } from "@repo/database/lib/client"; +import { deleteBlock } from "roamjs-components/writes"; +import canonicalRoamUrl from "./canonicalRoamUrl"; + +const matchImportedNodeSchemas = async ( + nodeSchemas: CrossAppNodeSchema[], +): Promise> => { + const result: Record = {}; + const nodeSchemasByRid = Object.fromEntries( + nodeSchemas.map((s) => [s.rid!, s]), + ); + const existing = await getImportedSourceRids(); + const localNodeSchemas = getDiscourseNodes(); + const localNodeSchemasByLabel = Object.fromEntries( + localNodeSchemas.map((s) => [s.text.toLowerCase(), s]), + ); + const localNodeSchemasByLocalId = Object.fromEntries( + localNodeSchemas.map((s) => [s.type, s]), + ); + + for (const [rid, schema] of Object.entries(nodeSchemasByRid)) { + let blockUid: string | undefined | null; + if (existing.has(rid)) { + blockUid = await findImportedNodeUidBySourceRid(rid); + } + if (blockUid) { + result[rid] = blockUid; + continue; + } else if (schema.localId in localNodeSchemasByLocalId) { + blockUid = localNodeSchemasByLocalId[schema.localId].type; + } else if (schema.label.toLowerCase() in localNodeSchemasByLabel) { + blockUid = localNodeSchemasByLabel[schema.label.toLowerCase()].type; + } else { + // create a new node schema + const node = await createDiscourseNodeType({ + label: schema.label, + template: schema.template, + // TODO: colour, other metadata? + }); + blockUid = node.type; + await writeImportedSourceIdentity({ + pageUid: blockUid, + sourceNodeRid: rid, + sourceModifiedAt: (schema.modifiedAt ?? new Date()).toISOString(), + }); + } + result[rid] = blockUid; + } + return result; +}; + +const matchImportedRelationSchemas = async ( + nodeSchemaRidToLocalId: Record, + relationTypeSchemas: CrossAppRelationTypeSchema[], + relationTripleSchemas: CrossAppRelationTripleSchema[], +): Promise> => { + const result: Record = {}; + const relationSchemas = getDiscourseRelations(); + const existing = await getImportedSourceRids(); + const relationTypeSchemasByRid = Object.fromEntries( + relationTypeSchemas.map((s) => [s.rid!, s]), + ); + const localRelationTripleSchemasByLocalId = Object.fromEntries( + relationSchemas.map((s) => [s.id, s]), + ); + + for (const tripleSchema of relationTripleSchemas) { + const rid = tripleSchema.rid!; + const { spaceUri } = ridToSpaceUriAndLocalId(rid); + let blockUid: string | undefined | null; + if (existing.has(rid)) { + blockUid = await findImportedNodeUidBySourceRid(rid); + } + if (blockUid) { + result[rid] = blockUid; + continue; + } + if (tripleSchema.localId in localRelationTripleSchemasByLocalId) { + blockUid = localRelationTripleSchemasByLocalId[tripleSchema.localId].id; + } else { + const { sourceType, destinationType, relation } = tripleSchema; + const sourceTypeRid = spaceUriAndLocalIdToRid( + spaceUri, + sourceType, + "schema", + ); + const destinationTypeRid = spaceUriAndLocalIdToRid( + spaceUri, + destinationType, + "schema", + ); + const source = nodeSchemaRidToLocalId[sourceTypeRid] ?? "missing"; + const destination = + nodeSchemaRidToLocalId[destinationTypeRid] ?? "missing"; + if (source === "missing" || destination === "missing") + throw new Error("Missing source or destination"); + const relationType = relation + ? relationTypeSchemasByRid[ + spaceUriAndLocalIdToRid(spaceUri, relation, "schema") + ] + : undefined; + + const label = tripleSchema.label ?? relationType?.label; + if (label === undefined) throw new Error("Could not get label"); + const complement = tripleSchema.complement ?? relationType?.complement; + if (complement === undefined) throw new Error("Could not get complement"); + const match = relationSchemas.filter( + (r) => + r.label.toLowerCase() === label.toLowerCase() && + r.source === source && + r.destination === destination, + ); + if (match.length > 1) { + throw new Error("multiple matches"); + } + if (match.length === 1) { + blockUid = match[0].id; + } else { + blockUid = await createRelationSchema({ + label, + complement, + source, + destination, + }); + await writeImportedSourceIdentity({ + pageUid: blockUid, + sourceNodeRid: rid, + sourceModifiedAt: ( + tripleSchema.modifiedAt ?? new Date() + ).toISOString(), + }); + } + result[rid] = blockUid; + } + } + return result; +}; + +const localSpaceUrl = canonicalRoamUrl(window.roamAlphaAPI.graph.name); + +const findTargetUid = async ( + localOrRid: string, + spaceUri: string, + ridType?: string, +): Promise => { + if (isRid(localOrRid)) { + const { spaceUri, sourceLocalId } = ridToSpaceUriAndLocalId(localOrRid); + if (spaceUri === localSpaceUrl) { + // check existence + const result = window.roamAlphaAPI.q( + `[:find (?e) :where [?e :block/uid "${sourceLocalId}"]]`, + ); + if (!result || result.length === 0) return null; + return sourceLocalId; + } + } else { + localOrRid = spaceUriAndLocalIdToRid( + spaceUri, + localOrRid, + ridType ?? "note", + ); + } + return await findImportedNodeUidBySourceRid(localOrRid); +}; + +const importRelations = async ( + schemaRidToLocalId: Record, + relations: CrossAppRelation[], +): Promise => { + const existing = await getImportedSourceRids(); + const allRelations = await getReifiedRelations(); + for (const relation of relations) { + const { rid: sourceNodeRid, source, destination, relationType } = relation; + if (sourceNodeRid === undefined) continue; + const { spaceUri } = ridToSpaceUriAndLocalId(sourceNodeRid); + const schemaRid = spaceUriAndLocalIdToRid(spaceUri, relationType, "schema"); + const relationBlockUid = schemaRidToLocalId[schemaRid]; + if (relationBlockUid === undefined) + throw new Error(`Missing relation type: ${relationType}`); + const sourceUid = await findTargetUid(source, spaceUri); + if (sourceUid === null) + throw new Error(`Missing relation source: ${source}`); + const destinationUid = await findTargetUid(destination, spaceUri); + if (destinationUid === null) + throw new Error(`Missing relation destination: ${destination}`); + if (existing.has(sourceNodeRid)) { + // Update existing + const existingRelUid = + await findImportedNodeUidBySourceRid(sourceNodeRid); + if (existingRelUid === null) + throw new Error("Could not get imported block"); + const existingRel = allRelations.find( + (r) => r.relationId === existingRelUid, + ); + if (existingRel === undefined) throw new Error("Could not find relation"); + if ( + existingRel.hasSchema === relationBlockUid && + existingRel.sourceUid === sourceUid && + existingRel.destinationUid === destinationUid + ) + continue; + // It was imported and modified. We could update, but easier to delete and recreate. + await deleteBlock(existingRelUid); + } + + const existingRel = allRelations.filter( + (r) => + r.hasSchema === relationBlockUid && + r.sourceUid === sourceUid && + r.destinationUid === destinationUid, + ); + if (existingRel.length > 1) throw new Error("Multiple matching relations"); + if (existingRel.length === 0) { + const uid = await createReifiedRelation({ + sourceUid, + destinationUid, + relationBlockUid, + tentative: true, + }); + await writeImportedSourceIdentity({ + pageUid: uid, + sourceNodeRid, + sourceModifiedAt: (relation.modifiedAt ?? new Date()).toISOString(), + }); + } + } +}; + +export const importSharedRelations = async ( + client: DGSupabaseClient, + spaceId: number, + futureImportRids?: string[], +) => { + const { relations, relTripleSchemas, relTypeSchemas, nodeSchemas } = + await discoverSharedRelations(client, spaceId, futureImportRids); + let ridToLocalId = await matchImportedNodeSchemas(nodeSchemas); + const relationSchemaMap = await matchImportedRelationSchemas( + ridToLocalId, + relTypeSchemas, + relTripleSchemas, + ); + ridToLocalId = { ...ridToLocalId, ...relationSchemaMap }; + await importRelations(ridToLocalId, relations); +}; diff --git a/apps/roam/src/utils/materializeSharedNode.ts b/apps/roam/src/utils/materializeSharedNode.ts index 599aed4a3..f07c40e51 100644 --- a/apps/roam/src/utils/materializeSharedNode.ts +++ b/apps/roam/src/utils/materializeSharedNode.ts @@ -67,7 +67,7 @@ type RoamMarkdownApi = { }; }; -const getRoamMarkdownApi = (): RoamMarkdownApi => +export const getRoamMarkdownApi = (): RoamMarkdownApi => window.roamAlphaAPI.data as unknown as RoamMarkdownApi; export const getErrorMessage = (error: unknown): string =>