From 41dfd9580bcfc85195ec3155939891c051f06bfa Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 26 Jun 2026 14:26:51 -0400 Subject: [PATCH 1/2] FEE-840 Add schema export for Obsidian. Adds an Export discourse graph schema command and settings entry that lets users select node types, relation types, relation triples, and templates and saves them to a dg-schema-.json file via the Electron save dialog. Includes the shared selection panel, modal shell, file dialog utility, and Zod validation schema that the upcoming import feature will also build on. Co-authored-by: Cursor --- .../src/components/ExportSpecsModal.tsx | 132 ++++++++ .../src/components/GeneralSettings.tsx | 22 ++ .../src/components/ReactRootModal.tsx | 27 ++ .../components/SchemaSelectionModalBody.tsx | 77 +++++ .../src/components/SchemaSelectionPanel.tsx | 307 ++++++++++++++++++ .../src/components/useSchemaSelection.ts | 242 ++++++++++++++ apps/obsidian/src/types.ts | 16 + .../src/utils/nativeJsonFileDialogs.ts | 121 +++++++ apps/obsidian/src/utils/registerCommands.ts | 9 + apps/obsidian/src/utils/specExport.ts | 131 ++++++++ apps/obsidian/src/utils/specValidation.ts | 83 +++++ 11 files changed, 1167 insertions(+) create mode 100644 apps/obsidian/src/components/ExportSpecsModal.tsx create mode 100644 apps/obsidian/src/components/ReactRootModal.tsx create mode 100644 apps/obsidian/src/components/SchemaSelectionModalBody.tsx create mode 100644 apps/obsidian/src/components/SchemaSelectionPanel.tsx create mode 100644 apps/obsidian/src/components/useSchemaSelection.ts create mode 100644 apps/obsidian/src/utils/nativeJsonFileDialogs.ts create mode 100644 apps/obsidian/src/utils/specExport.ts create mode 100644 apps/obsidian/src/utils/specValidation.ts diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx new file mode 100644 index 000000000..e418237bf --- /dev/null +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -0,0 +1,132 @@ +import { App, Notice } from "obsidian"; +import { useMemo, useState } from "react"; +import type DiscourseGraphPlugin from "~/index"; +import { exportSchemaSelection } from "~/utils/specExport"; +import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs"; +import { getDgSchemaFileName } from "~/utils/specValidation"; +import { getTemplateFiles } from "~/utils/templates"; +import { + getReferencedTemplateNames, + useSchemaSelection, + type SchemaSelectionSource, +} from "~/components/useSchemaSelection"; +import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody"; +import { ReactRootModal } from "~/components/ReactRootModal"; + +type ExportSpecsModalProps = { + plugin: DiscourseGraphPlugin; + onClose: () => void; +}; + +export const openExportSpecsModal = (plugin: DiscourseGraphPlugin): void => { + new ExportSpecsModal(plugin.app, plugin).open(); +}; + +const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { + const [isExporting, setIsExporting] = useState(false); + const outputFileName = getDgSchemaFileName(plugin.app.vault.getName()); + + const source = useMemo(() => { + return { + nodeTypes: plugin.settings.nodeTypes, + relationTypes: plugin.settings.relationTypes, + relationTriples: plugin.settings.discourseRelations, + templateNames: getTemplateFiles(plugin.app), + }; + }, [ + plugin.app, + plugin.settings.discourseRelations, + plugin.settings.nodeTypes, + plugin.settings.relationTypes, + ]); + + const selection = useSchemaSelection({ + source, + resetKey: "export", + initialTemplateNames: [...getReferencedTemplateNames(source.nodeTypes)], + }); + + const handleExport = async (): Promise => { + const payload = selection.asSelectionPayload(); + const hasSelection = + payload.nodeTypeIds.length > 0 || + payload.relationTypeIds.length > 0 || + payload.relationIds.length > 0 || + payload.templateNames.length > 0; + if (!hasSelection) { + new Notice("Select at least one schema item or template to export."); + return; + } + + setIsExporting(true); + try { + const result = await exportSchemaSelection({ + plugin, + selection: { + nodeTypeIds: payload.nodeTypeIds, + relationTypeIds: payload.relationTypeIds, + discourseRelationIds: payload.relationIds, + templateNames: payload.templateNames, + }, + }); + + const warningSuffix = + result.warnings.length > 0 + ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? "" : "s"})` + : ""; + + new Notice( + `Exported schema to ${result.filePath}${warningSuffix}.`, + 6000, + ); + + if (result.warnings.length > 0) { + for (const warning of result.warnings) { + new Notice(warning, 6000); + } + } + + onClose(); + } catch (error) { + if (error instanceof NativeFileDialogCancelledError) { + return; + } + console.error("Failed to export schema:", error); + const message = error instanceof Error ? error.message : String(error); + new Notice(`Schema export failed: ${message}`, 6000); + } finally { + setIsExporting(false); + } + }; + + return ( + new Notice(message)} + footerSecondaryLabel="Cancel" + onFooterSecondaryClick={onClose} + footerPrimaryLabel={isExporting ? "Exporting..." : "Export schema"} + onFooterPrimaryClick={() => void handleExport()} + isFooterPrimaryDisabled={isExporting} + /> + ); +}; + +export class ExportSpecsModal extends ReactRootModal { + private plugin: DiscourseGraphPlugin; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app); + this.plugin = plugin; + } + + protected renderContent() { + return ( + this.close()} /> + ); + } +} diff --git a/apps/obsidian/src/components/GeneralSettings.tsx b/apps/obsidian/src/components/GeneralSettings.tsx index 9666a1217..067aad072 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -3,6 +3,8 @@ import { usePlugin } from "./PluginContext"; import { setIcon } from "obsidian"; import SuggestInput from "./SuggestInput"; import { DiscourseGraphLogoIcon, SlackLogoIcon } from "./Icons"; +import { openExportSpecsModal } from "./ExportSpecsModal"; +import { getDgSchemaFileName } from "~/utils/specValidation"; const DOCS_URL = "https://discoursegraphs.com/docs/obsidian"; const COMMUNITY_URL = @@ -148,6 +150,7 @@ const GeneralSettings = () => { const [nodeTagHotkey, setNodeTagHotkey] = useState( plugin.settings.nodeTagHotkey, ); + const schemaFileName = getDgSchemaFileName(plugin.app.vault.getName()); const handleToggleChange = (newValue: boolean) => { setShowIdsInFrontmatter(newValue); @@ -298,6 +301,25 @@ const GeneralSettings = () => { +
+
+
Export discourse graph schema
+
+ Export selected node types, relation types, relation triples, and + templates to a JSON file named {schemaFileName}. +
+
+
+ +
+
+ ); diff --git a/apps/obsidian/src/components/ReactRootModal.tsx b/apps/obsidian/src/components/ReactRootModal.tsx new file mode 100644 index 000000000..566df4d77 --- /dev/null +++ b/apps/obsidian/src/components/ReactRootModal.tsx @@ -0,0 +1,27 @@ +import { App, Modal } from "obsidian"; +import { StrictMode, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +export abstract class ReactRootModal extends Modal { + private root: Root | null = null; + + constructor(app: App) { + super(app); + } + + protected abstract renderContent(): ReactNode; + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + this.root = createRoot(contentEl); + this.root.render({this.renderContent()}); + } + + onClose(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } +} diff --git a/apps/obsidian/src/components/SchemaSelectionModalBody.tsx b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx new file mode 100644 index 000000000..6a37e440a --- /dev/null +++ b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx @@ -0,0 +1,77 @@ +import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; +import type { ReactNode } from "react"; +import type { + SchemaSelectionSource, + SchemaSelectionState, +} from "~/components/useSchemaSelection"; + +type SchemaSelectionModalBodyProps = { + title: string; + description: string; + source: SchemaSelectionSource; + selection: SchemaSelectionState; + emptyTemplateText: string; + onDependencyViolation?: (message: string) => void; + beforePanel?: ReactNode; + afterPanel?: ReactNode; + footerSecondaryLabel: string; + onFooterSecondaryClick: () => void; + footerPrimaryLabel: string; + onFooterPrimaryClick: () => void; + isFooterPrimaryDisabled?: boolean; + isFooterSecondaryDisabled?: boolean; +}; + +export const SchemaSelectionModalBody = ({ + title, + description, + source, + selection, + emptyTemplateText, + onDependencyViolation, + beforePanel, + afterPanel, + footerSecondaryLabel, + onFooterSecondaryClick, + footerPrimaryLabel, + onFooterPrimaryClick, + isFooterPrimaryDisabled = false, + isFooterSecondaryDisabled = false, +}: SchemaSelectionModalBodyProps) => { + return ( +
+

{title}

+

{description}

+ + {beforePanel} + + + + {afterPanel} + +
+ + +
+
+ ); +}; diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx new file mode 100644 index 000000000..fd113dce2 --- /dev/null +++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx @@ -0,0 +1,307 @@ +import type { + SchemaSelectionSource, + SchemaSelectionState, +} from "~/components/useSchemaSelection"; + +type SchemaSelectionPanelProps = { + source: SchemaSelectionSource; + selection: SchemaSelectionState; + emptyTemplateText: string; + onDependencyViolation?: (message: string) => void; +}; + +export const SchemaSelectionPanel = ({ + source, + selection, + emptyTemplateText, + onDependencyViolation, +}: SchemaSelectionPanelProps) => { + const { + selectedNodeTypeIds, + selectedRelationTypeIds, + selectedRelationIds, + selectedTemplateNames, + requiredNodeTypeIds, + requiredRelationTypeIds, + selectAllNodeTypes, + deselectOptionalNodeTypes, + toggleNodeType, + selectAllRelationTypes, + deselectOptionalRelationTypes, + toggleRelationType, + selectAllRelationTriples, + deselectAllRelationTriples, + toggleRelationTriple, + selectAllTemplates, + deselectAllTemplates, + toggleTemplate, + } = selection; + + const nodeTypeById = new Map( + source.nodeTypes.map((nodeType) => [nodeType.id, nodeType]), + ); + const relationTypeById = new Map( + source.relationTypes.map((relationType) => [relationType.id, relationType]), + ); + const templateToNodeTypeNames = new Map(); + for (const nodeType of source.nodeTypes) { + if (!nodeType.template) continue; + const current = templateToNodeTypeNames.get(nodeType.template) ?? []; + current.push(nodeType.name); + templateToNodeTypeNames.set(nodeType.template, current); + } + for (const [ + templateName, + nodeTypeNames, + ] of templateToNodeTypeNames.entries()) { + templateToNodeTypeNames.set( + templateName, + [...new Set(nodeTypeNames)].sort((left, right) => + left.localeCompare(right), + ), + ); + } + const referencedTemplateNames = new Set(templateToNodeTypeNames.keys()); + + return ( + <> +
+
Selection summary
+
+ {selectedNodeTypeIds.size} node type(s) + {selectedRelationTypeIds.size} relation type(s) + {selectedRelationIds.size} relation triple(s) + {selectedTemplateNames.size} template(s) +
+
+ +
+
+
+

Node types

+
+ + +
+
+
+ {source.nodeTypes.map((nodeType) => { + const isRequired = requiredNodeTypeIds.has(nodeType.id); + return ( + + ); + })} +
+
+ +
+
+

Relation types

+
+ + +
+
+
+ {source.relationTypes.map((relationType) => { + const isRequired = requiredRelationTypeIds.has(relationType.id); + return ( + + ); + })} +
+
+ +
+
+

Relation triples

+
+ + +
+
+
+ {source.relationTriples.map((relation) => { + const sourceName = + nodeTypeById.get(relation.sourceId)?.name ?? relation.sourceId; + const destinationName = + nodeTypeById.get(relation.destinationId)?.name ?? + relation.destinationId; + const relationTypeLabel = + relationTypeById.get(relation.relationshipTypeId)?.label ?? + relation.relationshipTypeId; + + return ( + + ); + })} +
+
+ +
+
+

Templates

+
+ + +
+
+ {source.templateNames.length === 0 ? ( +

{emptyTemplateText}

+ ) : ( +
+ {source.templateNames.map((templateName) => ( + + ))} +
+ )} +
+
+ + ); +}; diff --git a/apps/obsidian/src/components/useSchemaSelection.ts b/apps/obsidian/src/components/useSchemaSelection.ts new file mode 100644 index 000000000..319f6047f --- /dev/null +++ b/apps/obsidian/src/components/useSchemaSelection.ts @@ -0,0 +1,242 @@ +import { useEffect, useMemo, useState } from "react"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, +} from "~/types"; + +export type SchemaSelectionSource = { + nodeTypes: Pick[]; + relationTypes: Pick[]; + relationTriples: Pick< + DiscourseRelation, + "id" | "sourceId" | "destinationId" | "relationshipTypeId" + >[]; + templateNames: string[]; +}; + +type SelectionToggleResult = { + ok: boolean; + reason?: string; +}; + +export type SchemaSelectionState = { + selectedNodeTypeIds: Set; + selectedRelationTypeIds: Set; + selectedRelationIds: Set; + selectedTemplateNames: Set; + requiredNodeTypeIds: Set; + requiredRelationTypeIds: Set; + selectAllNodeTypes: () => void; + deselectOptionalNodeTypes: () => void; + toggleNodeType: ( + nodeTypeId: string, + shouldSelect: boolean, + ) => SelectionToggleResult; + selectAllRelationTypes: () => void; + deselectOptionalRelationTypes: () => void; + toggleRelationType: ( + relationTypeId: string, + shouldSelect: boolean, + ) => SelectionToggleResult; + selectAllRelationTriples: () => void; + deselectAllRelationTriples: () => void; + toggleRelationTriple: (relationId: string, shouldSelect: boolean) => void; + selectAllTemplates: () => void; + deselectAllTemplates: () => void; + toggleTemplate: (templateName: string, shouldSelect: boolean) => void; + asSelectionPayload: () => { + nodeTypeIds: string[]; + relationTypeIds: string[]; + relationIds: string[]; + templateNames: string[]; + }; +}; + +const updateSet = ( + previousSet: Set, + id: string, + shouldSelect: boolean, +): Set => { + const nextSet = new Set(previousSet); + if (shouldSelect) { + nextSet.add(id); + } else { + nextSet.delete(id); + } + return nextSet; +}; + +export const getReferencedTemplateNames = ( + nodeTypes: SchemaSelectionSource["nodeTypes"], +): Set => { + return new Set( + nodeTypes + .map((nodeType) => nodeType.template) + .filter((template): template is string => !!template), + ); +}; + +export const useSchemaSelection = ({ + source, + initialTemplateNames, + resetKey, +}: { + source: SchemaSelectionSource; + /** + * Template names to pre-select on mount and on reset. Defaults to all + * templates in source when not provided. + */ + initialTemplateNames?: string[]; + resetKey: string; +}): SchemaSelectionState => { + const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState>( + () => new Set(source.nodeTypes.map((nodeType) => nodeType.id)), + ); + const [selectedRelationTypeIds, setSelectedRelationTypeIds] = useState< + Set + >(() => new Set(source.relationTypes.map((relationType) => relationType.id))); + const [selectedRelationIds, setSelectedRelationIds] = useState>( + () => new Set(source.relationTriples.map((relation) => relation.id)), + ); + const [selectedTemplateNames, setSelectedTemplateNames] = useState< + Set + >(() => new Set(initialTemplateNames ?? source.templateNames)); + + // resetKey is the only trigger; source and initialTemplateNames are read + // from the current render's closure when resetKey changes. + useEffect(() => { + setSelectedNodeTypeIds( + new Set(source.nodeTypes.map((nodeType) => nodeType.id)), + ); + setSelectedRelationTypeIds( + new Set(source.relationTypes.map((relationType) => relationType.id)), + ); + setSelectedRelationIds( + new Set(source.relationTriples.map((relation) => relation.id)), + ); + setSelectedTemplateNames( + new Set(initialTemplateNames ?? source.templateNames), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetKey]); + + const requiredRelationTypeIds = useMemo(() => { + const requiredIds = new Set(); + for (const relation of source.relationTriples) { + if (selectedRelationIds.has(relation.id)) { + requiredIds.add(relation.relationshipTypeId); + } + } + return requiredIds; + }, [source.relationTriples, selectedRelationIds]); + + const requiredNodeTypeIds = useMemo(() => { + const requiredIds = new Set(); + for (const relation of source.relationTriples) { + if (!selectedRelationIds.has(relation.id)) { + continue; + } + requiredIds.add(relation.sourceId); + requiredIds.add(relation.destinationId); + } + return requiredIds; + }, [source.relationTriples, selectedRelationIds]); + + useEffect(() => { + setSelectedRelationTypeIds((previousSet) => { + const nextSet = new Set(previousSet); + let didChange = false; + for (const relationTypeId of requiredRelationTypeIds) { + if (!nextSet.has(relationTypeId)) { + nextSet.add(relationTypeId); + didChange = true; + } + } + return didChange ? nextSet : previousSet; + }); + }, [requiredRelationTypeIds]); + + useEffect(() => { + setSelectedNodeTypeIds((previousSet) => { + const nextSet = new Set(previousSet); + let didChange = false; + for (const nodeTypeId of requiredNodeTypeIds) { + if (!nextSet.has(nodeTypeId)) { + nextSet.add(nodeTypeId); + didChange = true; + } + } + return didChange ? nextSet : previousSet; + }); + }, [requiredNodeTypeIds]); + + return { + selectedNodeTypeIds, + selectedRelationTypeIds, + selectedRelationIds, + selectedTemplateNames, + requiredNodeTypeIds, + requiredRelationTypeIds, + selectAllNodeTypes: () => + setSelectedNodeTypeIds( + new Set(source.nodeTypes.map((nodeType) => nodeType.id)), + ), + deselectOptionalNodeTypes: () => + setSelectedNodeTypeIds(new Set([...requiredNodeTypeIds])), + toggleNodeType: (nodeTypeId, shouldSelect) => { + if (!shouldSelect && requiredNodeTypeIds.has(nodeTypeId)) { + return { + ok: false, + reason: + "This node type is required by a selected relation triple. Remove the triple first.", + }; + } + setSelectedNodeTypeIds((previousSet) => + updateSet(previousSet, nodeTypeId, shouldSelect), + ); + return { ok: true }; + }, + selectAllRelationTypes: () => + setSelectedRelationTypeIds( + new Set(source.relationTypes.map((relationType) => relationType.id)), + ), + deselectOptionalRelationTypes: () => + setSelectedRelationTypeIds(new Set([...requiredRelationTypeIds])), + toggleRelationType: (relationTypeId, shouldSelect) => { + if (!shouldSelect && requiredRelationTypeIds.has(relationTypeId)) { + return { + ok: false, + reason: + "This relation type is required by a selected relation triple. Remove the triple first.", + }; + } + setSelectedRelationTypeIds((previousSet) => + updateSet(previousSet, relationTypeId, shouldSelect), + ); + return { ok: true }; + }, + selectAllRelationTriples: () => + setSelectedRelationIds( + new Set(source.relationTriples.map((relation) => relation.id)), + ), + deselectAllRelationTriples: () => setSelectedRelationIds(new Set()), + toggleRelationTriple: (relationId, shouldSelect) => + setSelectedRelationIds((previousSet) => + updateSet(previousSet, relationId, shouldSelect), + ), + selectAllTemplates: () => + setSelectedTemplateNames(new Set(source.templateNames)), + deselectAllTemplates: () => setSelectedTemplateNames(new Set()), + toggleTemplate: (templateName, shouldSelect) => + setSelectedTemplateNames((previousSet) => + updateSet(previousSet, templateName, shouldSelect), + ), + asSelectionPayload: () => ({ + nodeTypeIds: [...selectedNodeTypeIds], + relationTypeIds: [...selectedRelationTypeIds], + relationIds: [...selectedRelationIds], + templateNames: [...selectedTemplateNames], + }), + }; +}; diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index f7bc3fc41..a43ba9ae3 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -117,4 +117,20 @@ export type ImportFolderMetadata = { userName?: string; }; +export type DiscourseSchemaTemplate = { + name: string; + content: string; +}; + +export type DiscourseSchemaFile = { + version: number; + exportedAt: string; + pluginVersion: string; + vaultName: string; + nodeTypes: DiscourseNode[]; + relationTypes: DiscourseRelationType[]; + discourseRelations: DiscourseRelation[]; + templates: DiscourseSchemaTemplate[]; +}; + export const VIEW_TYPE_DISCOURSE_CONTEXT = "discourse-context-view"; diff --git a/apps/obsidian/src/utils/nativeJsonFileDialogs.ts b/apps/obsidian/src/utils/nativeJsonFileDialogs.ts new file mode 100644 index 000000000..9a784c61e --- /dev/null +++ b/apps/obsidian/src/utils/nativeJsonFileDialogs.ts @@ -0,0 +1,121 @@ +type SaveDialogResult = { + canceled: boolean; + filePath?: string; +}; + +type OpenDialogResult = { + canceled: boolean; + filePaths: string[]; +}; + +type ElectronDialog = { + showSaveDialog: (options: { + title: string; + defaultPath: string; + filters: Array<{ name: string; extensions: string[] }>; + }) => Promise; + showOpenDialog: (options: { + title: string; + properties: string[]; + filters: Array<{ name: string; extensions: string[] }>; + }) => Promise; +}; + +type ElectronLike = { + dialog?: ElectronDialog; + remote?: { + dialog?: ElectronDialog; + }; +}; + +type FsPromisesLike = { + readFile: (path: string, encoding: string) => Promise; + writeFile: (path: string, data: string, encoding: string) => Promise; +}; + +type ElectronWindow = Window & { + require: (name: string) => unknown; +}; + +export class NativeFileDialogCancelledError extends Error { + constructor() { + super("File dialog cancelled"); + this.name = "NativeFileDialogCancelledError"; + } +} + +const getElectronWindow = (): ElectronWindow => { + if (typeof window === "undefined" || !("require" in window)) { + throw new Error( + "Schema export/import requires Obsidian desktop (Electron).", + ); + } + return window as ElectronWindow; +}; + +const getFsPromises = (electronWindow: ElectronWindow): FsPromisesLike => { + const fsPromises = electronWindow.require("fs/promises"); + if ( + typeof fsPromises !== "object" || + fsPromises === null || + !("readFile" in fsPromises) || + !("writeFile" in fsPromises) + ) { + throw new Error("Unable to access filesystem read/write APIs."); + } + return fsPromises as FsPromisesLike; +}; + +const getElectronDialog = (electronWindow: ElectronWindow): ElectronDialog => { + const electron = electronWindow.require("electron") as ElectronLike; + const dialog = electron.dialog ?? electron.remote?.dialog; + if (!dialog?.showSaveDialog || !dialog.showOpenDialog) { + throw new Error("Unable to access Electron file dialogs."); + } + return dialog; +}; + +export const saveJsonToUserLocation = async ({ + title, + fileName, + content, +}: { + title: string; + fileName: string; + content: string; +}): Promise => { + const electronWindow = getElectronWindow(); + const dialog = getElectronDialog(electronWindow); + const result = await dialog.showSaveDialog({ + title, + defaultPath: fileName, + filters: [{ name: "JSON files", extensions: ["json"] }], + }); + if (result.canceled || !result.filePath) { + throw new NativeFileDialogCancelledError(); + } + const fsPromises = getFsPromises(electronWindow); + await fsPromises.writeFile(result.filePath, content, "utf8"); + return result.filePath; +}; + +export const openJsonFromUserLocation = async ({ + title, +}: { + title: string; +}): Promise<{ content: string; sourcePath: string }> => { + const electronWindow = getElectronWindow(); + const dialog = getElectronDialog(electronWindow); + const result = await dialog.showOpenDialog({ + title, + properties: ["openFile"], + filters: [{ name: "JSON files", extensions: ["json"] }], + }); + if (result.canceled || !result.filePaths[0]) { + throw new NativeFileDialogCancelledError(); + } + const fsPromises = getFsPromises(electronWindow); + const sourcePath = result.filePaths[0]; + const content = await fsPromises.readFile(sourcePath, "utf8"); + return { content, sourcePath }; +}; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index ea7e019f6..962008695 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -4,6 +4,7 @@ import { NodeTypeModal } from "~/components/NodeTypeModal"; import ModifyNodeModal from "~/components/ModifyNodeModal"; import { BulkIdentifyDiscourseNodesModal } from "~/components/BulkIdentifyDiscourseNodesModal"; import { ImportNodesModal } from "~/components/ImportNodesModal"; +import { openExportSpecsModal } from "~/components/ExportSpecsModal"; import { convertPageToDiscourseNode, createDiscourseNode } from "./createNode"; import { refreshAllImportedFiles } from "./importNodes"; import { VIEW_TYPE_MARKDOWN, VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; @@ -194,6 +195,14 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); + plugin.addCommand({ + id: "export-dg-schema", + name: "Export discourse graph schema", + callback: () => { + openExportSpecsModal(plugin); + }, + }); + plugin.addCommand({ id: "toggle-discourse-context", name: "Toggle discourse context", diff --git a/apps/obsidian/src/utils/specExport.ts b/apps/obsidian/src/utils/specExport.ts new file mode 100644 index 000000000..6c70408c7 --- /dev/null +++ b/apps/obsidian/src/utils/specExport.ts @@ -0,0 +1,131 @@ +import { TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseSchemaFile, + DiscourseSchemaTemplate, +} from "~/types"; +import { + DG_SCHEMA_EXPORT_VERSION, + getDgSchemaFileName, +} from "~/utils/specValidation"; +import { getTemplatePluginInfo } from "~/utils/templates"; +import { saveJsonToUserLocation } from "~/utils/nativeJsonFileDialogs"; + +export type SpecExportSelection = { + nodeTypeIds: string[]; + relationTypeIds: string[]; + discourseRelationIds: string[]; + templateNames: string[]; +}; + +export type SpecExportResult = { + filePath: string; + warnings: string[]; +}; + +const asMap = (items: T[]): Map => { + return new Map(items.map((item) => [item.id, item])); +}; + +const getTemplateContents = async ({ + plugin, + templateNames, +}: { + plugin: DiscourseGraphPlugin; + templateNames: string[]; +}): Promise<{ templates: DiscourseSchemaTemplate[]; warnings: string[] }> => { + const warnings: string[] = []; + const templates: DiscourseSchemaTemplate[] = []; + const { isEnabled, folderPath } = getTemplatePluginInfo(plugin.app); + + if (!isEnabled || !folderPath) { + if (templateNames.length > 0) { + warnings.push( + "Templates plugin is not enabled or folder is not configured; template content was skipped.", + ); + } + return { templates, warnings }; + } + + for (const templateName of templateNames) { + const templatePath = `${folderPath}/${templateName}.md`; + const templateFile = plugin.app.vault.getAbstractFileByPath(templatePath); + + if (!(templateFile instanceof TFile)) { + warnings.push(`Template file not found: ${templateName}.md`); + continue; + } + + const content = await plugin.app.vault.read(templateFile); + templates.push({ name: templateName, content }); + } + + return { templates, warnings }; +}; + +const buildSchemaExportPayload = async ({ + plugin, + selection, +}: { + plugin: DiscourseGraphPlugin; + selection: SpecExportSelection; +}): Promise<{ payload: DiscourseSchemaFile; warnings: string[] }> => { + const nodeTypeMap = asMap(plugin.settings.nodeTypes); + const relationTypeMap = asMap(plugin.settings.relationTypes); + const discourseRelationMap = asMap(plugin.settings.discourseRelations); + + const selectedNodeTypes: DiscourseNode[] = selection.nodeTypeIds + .map((id) => nodeTypeMap.get(id)) + .filter((nodeType): nodeType is DiscourseNode => !!nodeType); + + const selectedRelationTypes = selection.relationTypeIds + .map((id) => relationTypeMap.get(id)) + .filter((relationType) => !!relationType); + + const selectedDiscourseRelations: DiscourseRelation[] = + selection.discourseRelationIds + .map((id) => discourseRelationMap.get(id)) + .filter((relation): relation is DiscourseRelation => !!relation); + + const { templates, warnings } = await getTemplateContents({ + plugin, + templateNames: selection.templateNames, + }); + + const payload: DiscourseSchemaFile = { + version: DG_SCHEMA_EXPORT_VERSION, + exportedAt: new Date().toISOString(), + pluginVersion: plugin.manifest.version, + vaultName: plugin.app.vault.getName(), + nodeTypes: selectedNodeTypes, + relationTypes: selectedRelationTypes, + discourseRelations: selectedDiscourseRelations, + templates, + }; + + return { payload, warnings }; +}; + +export const exportSchemaSelection = async ({ + plugin, + selection, +}: { + plugin: DiscourseGraphPlugin; + selection: SpecExportSelection; +}): Promise => { + const { payload, warnings } = await buildSchemaExportPayload({ + plugin, + selection, + }); + const serializedPayload = JSON.stringify(payload, null, 2); + const fileName = getDgSchemaFileName(plugin.app.vault.getName()); + const filePath = await saveJsonToUserLocation({ + title: "Export discourse graph schema", + fileName, + content: serializedPayload, + }); + + return { filePath, warnings }; +}; diff --git a/apps/obsidian/src/utils/specValidation.ts b/apps/obsidian/src/utils/specValidation.ts new file mode 100644 index 000000000..b9662e548 --- /dev/null +++ b/apps/obsidian/src/utils/specValidation.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; +import type { DiscourseSchemaFile } from "~/types"; + +export const DG_SCHEMA_EXPORT_VERSION = 1; + +const discourseNodeSchema = z.object({ + id: z.string(), + name: z.string(), + format: z.string(), + template: z.string().optional(), + description: z.string().optional(), + shortcut: z.string().optional(), + color: z.string().optional(), + tag: z.string().optional(), + keyImage: z.boolean().optional(), + folderPath: z.string().optional(), + created: z.number(), + modified: z.number(), + importedFromRid: z.string().optional(), + authorId: z.number().optional(), +}); + +const relationImportStatusSchema = z.enum(["provisional", "accepted"]); + +const discourseRelationTypeSchema = z.object({ + id: z.string(), + label: z.string(), + complement: z.string(), + color: z.string(), + created: z.number(), + modified: z.number(), + importedFromRid: z.string().optional(), + status: relationImportStatusSchema.optional(), + authorId: z.number().optional(), +}); + +const discourseRelationSchema = z.object({ + id: z.string(), + sourceId: z.string(), + destinationId: z.string(), + relationshipTypeId: z.string(), + created: z.number(), + modified: z.number(), + importedFromRid: z.string().optional(), + status: relationImportStatusSchema.optional(), + authorId: z.number().optional(), +}); + +const templateExportSchema = z.object({ + name: z.string(), + content: z.string(), +}); + +export const dgSchemaFileSchema = z.object({ + version: z.literal(DG_SCHEMA_EXPORT_VERSION), + exportedAt: z.string(), + pluginVersion: z.string(), + vaultName: z.string(), + nodeTypes: z.array(discourseNodeSchema), + relationTypes: z.array(discourseRelationTypeSchema), + discourseRelations: z.array(discourseRelationSchema), + templates: z.array(templateExportSchema), +}); + +const normalizeToKebabCase = (value: string): string => { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); +}; + +export const getDgSchemaFileName = (vaultName?: string): string => { + const normalizedVaultName = vaultName ? normalizeToKebabCase(vaultName) : ""; + const safeVaultName = + normalizedVaultName.length > 0 ? normalizedVaultName : "vault"; + return `dg-schema-${safeVaultName}.json`; +}; + +export const parseDgSchemaFile = (value: unknown): DiscourseSchemaFile => { + return dgSchemaFileSchema.parse(value) as DiscourseSchemaFile; +}; From 3ee6d9a1bcd3caba79241bbdefc8ef3a54327d5d Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 26 Jun 2026 15:50:58 -0400 Subject: [PATCH 2/2] fix: filter stale template references against actual template files on export Co-authored-by: Cursor --- apps/obsidian/src/components/ExportSpecsModal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx index e418237bf..fa02524b5 100644 --- a/apps/obsidian/src/components/ExportSpecsModal.tsx +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -43,7 +43,9 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { const selection = useSchemaSelection({ source, resetKey: "export", - initialTemplateNames: [...getReferencedTemplateNames(source.nodeTypes)], + initialTemplateNames: [ + ...getReferencedTemplateNames(source.nodeTypes), + ].filter((name) => source.templateNames.includes(name)), }); const handleExport = async (): Promise => {