diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx new file mode 100644 index 000000000..fa02524b5 --- /dev/null +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -0,0 +1,134 @@ +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), + ].filter((name) => source.templateNames.includes(name)), + }); + + 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/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 }; +};