From c64b70d47df11bc71dca6c9edafea240e889b20b Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:39:14 -0400 Subject: [PATCH 1/9] ENG-1976 Add schema export command to Obsidian --- .../src/components/ExportSpecsModal.tsx | 134 ++++++++++++++++++ .../src/components/GeneralSettings.tsx | 22 +++ apps/obsidian/src/utils/registerCommands.ts | 9 ++ apps/obsidian/src/utils/specExport.ts | 131 +++++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 apps/obsidian/src/components/ExportSpecsModal.tsx create mode 100644 apps/obsidian/src/utils/specExport.ts 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 }; +}; From b437189966668fc91c7ccb72caa530cb636b26b3 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 15:43:24 -0400 Subject: [PATCH 2/9] ENG-1976 Inline Modal boilerplate in ExportSpecsModal (remove ReactRootModal abstraction) --- .../src/components/ExportSpecsModal.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx index fa02524b5..b6e70fe9e 100644 --- a/apps/obsidian/src/components/ExportSpecsModal.tsx +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -1,5 +1,6 @@ -import { App, Notice } from "obsidian"; -import { useMemo, useState } from "react"; +import { App, Modal, Notice } from "obsidian"; +import { StrictMode, useMemo, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; import type DiscourseGraphPlugin from "~/index"; import { exportSchemaSelection } from "~/utils/specExport"; import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs"; @@ -11,7 +12,6 @@ import { type SchemaSelectionSource, } from "~/components/useSchemaSelection"; import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody"; -import { ReactRootModal } from "~/components/ReactRootModal"; type ExportSpecsModalProps = { plugin: DiscourseGraphPlugin; @@ -118,17 +118,29 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { ); }; -export class ExportSpecsModal extends ReactRootModal { +export class ExportSpecsModal extends Modal { private plugin: DiscourseGraphPlugin; + private root: Root | null = null; constructor(app: App, plugin: DiscourseGraphPlugin) { super(app); this.plugin = plugin; } - protected renderContent() { - return ( - this.close()} /> + onOpen(): void { + this.contentEl.empty(); + this.root = createRoot(this.contentEl); + this.root.render( + + this.close()} /> + , ); } + + onClose(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } } From e97671b1c344985021e8aaaf5483f4d21702fb3e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 22:13:32 -0400 Subject: [PATCH 3/9] ENG-1976 Remove console.error (violates Obsidian plugin guidelines) --- apps/obsidian/src/components/ExportSpecsModal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx index b6e70fe9e..2df49711c 100644 --- a/apps/obsidian/src/components/ExportSpecsModal.tsx +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -93,7 +93,6 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { 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 { From fbf3e3f27a50eeb6f74cc0b72c85451f297ecd16 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 23:15:52 -0400 Subject: [PATCH 4/9] ENG-1976 Remove emptyTemplateText prop from ExportSpecsModal call site Co-Authored-By: Claude Sonnet 4.6 --- apps/obsidian/src/components/ExportSpecsModal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx index 2df49711c..135171318 100644 --- a/apps/obsidian/src/components/ExportSpecsModal.tsx +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -106,7 +106,6 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { description={`Select the node types, relation types, relation triples, and templates to include in ${outputFileName}.`} source={source} selection={selection} - emptyTemplateText="No templates found in your Templates folder." onDependencyViolation={(message) => new Notice(message)} footerSecondaryLabel="Cancel" onFooterSecondaryClick={onClose} From b8aac3e7f6fdb39f33408c093cc33ba00fb56352 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 12:51:38 -0400 Subject: [PATCH 5/9] ENG-1976 Address review: use SchemaSelection type, simplify ExportSpecsModal constructor, fix type guard, batch warnings - specExport.ts: drop SpecExportSelection, import SchemaSelection from ~/types; add type guard on selectedRelationTypes - ExportSpecsModal.tsx: constructor takes only plugin (extracts app internally); remove plugin.app from useMemo deps; pass payload directly to exportSchemaSelection; batch warnings into one Notice - registerCommands.ts: simplify callback to single expression - GeneralSettings.tsx: remove redundant void cast Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/ExportSpecsModal.tsx | 22 ++++++------------- .../src/components/GeneralSettings.tsx | 2 +- apps/obsidian/src/utils/registerCommands.ts | 4 +--- apps/obsidian/src/utils/specExport.ts | 22 +++++++++---------- 4 files changed, 19 insertions(+), 31 deletions(-) diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx index 135171318..e2714e511 100644 --- a/apps/obsidian/src/components/ExportSpecsModal.tsx +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -1,4 +1,4 @@ -import { App, Modal, Notice } from "obsidian"; +import { Modal, Notice } from "obsidian"; import { StrictMode, useMemo, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import type DiscourseGraphPlugin from "~/index"; @@ -19,7 +19,7 @@ type ExportSpecsModalProps = { }; export const openExportSpecsModal = (plugin: DiscourseGraphPlugin): void => { - new ExportSpecsModal(plugin.app, plugin).open(); + new ExportSpecsModal(plugin).open(); }; const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { @@ -34,7 +34,6 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { templateNames: getTemplateFiles(plugin.app), }; }, [ - plugin.app, plugin.settings.discourseRelations, plugin.settings.nodeTypes, plugin.settings.relationTypes, @@ -53,7 +52,7 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { const hasSelection = payload.nodeTypeIds.length > 0 || payload.relationTypeIds.length > 0 || - payload.relationIds.length > 0 || + payload.discourseRelationIds.length > 0 || payload.templateNames.length > 0; if (!hasSelection) { new Notice("Select at least one schema item or template to export."); @@ -64,12 +63,7 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { try { const result = await exportSchemaSelection({ plugin, - selection: { - nodeTypeIds: payload.nodeTypeIds, - relationTypeIds: payload.relationTypeIds, - discourseRelationIds: payload.relationIds, - templateNames: payload.templateNames, - }, + selection: payload, }); const warningSuffix = @@ -83,9 +77,7 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { ); if (result.warnings.length > 0) { - for (const warning of result.warnings) { - new Notice(warning, 6000); - } + new Notice(`Export warnings:\n${result.warnings.join("\n")}`, 6000); } onClose(); @@ -120,8 +112,8 @@ export class ExportSpecsModal extends Modal { private plugin: DiscourseGraphPlugin; private root: Root | null = null; - constructor(app: App, plugin: DiscourseGraphPlugin) { - super(app); + constructor(plugin: DiscourseGraphPlugin) { + super(plugin.app); this.plugin = plugin; } diff --git a/apps/obsidian/src/components/GeneralSettings.tsx b/apps/obsidian/src/components/GeneralSettings.tsx index 067aad072..9c05c07d2 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -313,7 +313,7 @@ const GeneralSettings = () => { diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 962008695..29e7285d1 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -198,9 +198,7 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { plugin.addCommand({ id: "export-dg-schema", name: "Export discourse graph schema", - callback: () => { - openExportSpecsModal(plugin); - }, + callback: () => openExportSpecsModal(plugin), }); plugin.addCommand({ diff --git a/apps/obsidian/src/utils/specExport.ts b/apps/obsidian/src/utils/specExport.ts index 6c70408c7..411d76c9d 100644 --- a/apps/obsidian/src/utils/specExport.ts +++ b/apps/obsidian/src/utils/specExport.ts @@ -3,8 +3,10 @@ import type DiscourseGraphPlugin from "~/index"; import type { DiscourseNode, DiscourseRelation, + DiscourseRelationType, DiscourseSchemaFile, DiscourseSchemaTemplate, + SchemaSelection, } from "~/types"; import { DG_SCHEMA_EXPORT_VERSION, @@ -13,13 +15,6 @@ import { 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[]; @@ -70,7 +65,7 @@ const buildSchemaExportPayload = async ({ selection, }: { plugin: DiscourseGraphPlugin; - selection: SpecExportSelection; + selection: SchemaSelection; }): Promise<{ payload: DiscourseSchemaFile; warnings: string[] }> => { const nodeTypeMap = asMap(plugin.settings.nodeTypes); const relationTypeMap = asMap(plugin.settings.relationTypes); @@ -80,9 +75,12 @@ const buildSchemaExportPayload = async ({ .map((id) => nodeTypeMap.get(id)) .filter((nodeType): nodeType is DiscourseNode => !!nodeType); - const selectedRelationTypes = selection.relationTypeIds - .map((id) => relationTypeMap.get(id)) - .filter((relationType) => !!relationType); + const selectedRelationTypes: DiscourseRelationType[] = + selection.relationTypeIds + .map((id) => relationTypeMap.get(id)) + .filter( + (relationType): relationType is DiscourseRelationType => !!relationType, + ); const selectedDiscourseRelations: DiscourseRelation[] = selection.discourseRelationIds @@ -113,7 +111,7 @@ export const exportSchemaSelection = async ({ selection, }: { plugin: DiscourseGraphPlugin; - selection: SpecExportSelection; + selection: SchemaSelection; }): Promise => { const { payload, warnings } = await buildSchemaExportPayload({ plugin, From e9917e0492a57b7e419ee3af60ff8fa79c8aea10 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 13:00:43 -0400 Subject: [PATCH 6/9] ENG-1976 Simplify buildSchemaExportPayload: filter settings arrays directly Replace asMap + map + filter with Set membership filter over settings arrays. Co-Authored-By: Claude Sonnet 4.6 --- apps/obsidian/src/utils/specExport.ts | 39 +++++++++------------------ 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/apps/obsidian/src/utils/specExport.ts b/apps/obsidian/src/utils/specExport.ts index 411d76c9d..5501df05e 100644 --- a/apps/obsidian/src/utils/specExport.ts +++ b/apps/obsidian/src/utils/specExport.ts @@ -1,9 +1,6 @@ import { TFile } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import type { - DiscourseNode, - DiscourseRelation, - DiscourseRelationType, DiscourseSchemaFile, DiscourseSchemaTemplate, SchemaSelection, @@ -20,10 +17,6 @@ export type SpecExportResult = { warnings: string[]; }; -const asMap = (items: T[]): Map => { - return new Map(items.map((item) => [item.id, item])); -}; - const getTemplateContents = async ({ plugin, templateNames, @@ -67,25 +60,19 @@ const buildSchemaExportPayload = async ({ plugin: DiscourseGraphPlugin; selection: SchemaSelection; }): 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: DiscourseRelationType[] = - selection.relationTypeIds - .map((id) => relationTypeMap.get(id)) - .filter( - (relationType): relationType is DiscourseRelationType => !!relationType, - ); - - const selectedDiscourseRelations: DiscourseRelation[] = - selection.discourseRelationIds - .map((id) => discourseRelationMap.get(id)) - .filter((relation): relation is DiscourseRelation => !!relation); + const selectedNodeTypeIds = new Set(selection.nodeTypeIds); + const selectedRelationTypeIds = new Set(selection.relationTypeIds); + const selectedDiscourseRelationIds = new Set(selection.discourseRelationIds); + + const selectedNodeTypes = plugin.settings.nodeTypes.filter((nt) => + selectedNodeTypeIds.has(nt.id), + ); + const selectedRelationTypes = plugin.settings.relationTypes.filter((rt) => + selectedRelationTypeIds.has(rt.id), + ); + const selectedDiscourseRelations = plugin.settings.discourseRelations.filter( + (dr) => selectedDiscourseRelationIds.has(dr.id), + ); const { templates, warnings } = await getTemplateContents({ plugin, From 944257b1869363afa80d05d51e4fa12f4ce7036f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 13:06:08 -0400 Subject: [PATCH 7/9] =?UTF-8?q?ENG-1976=20Remove=20useMemo=20from=20source?= =?UTF-8?q?=20=E2=80=94=20settings=20are=20stable=20for=20modal=20lifetime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/ExportSpecsModal.tsx | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx index e2714e511..9eed1fe8b 100644 --- a/apps/obsidian/src/components/ExportSpecsModal.tsx +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -1,5 +1,5 @@ import { Modal, Notice } from "obsidian"; -import { StrictMode, useMemo, useState } from "react"; +import { StrictMode, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import type DiscourseGraphPlugin from "~/index"; import { exportSchemaSelection } from "~/utils/specExport"; @@ -9,7 +9,6 @@ import { getTemplateFiles } from "~/utils/templates"; import { getReferencedTemplateNames, useSchemaSelection, - type SchemaSelectionSource, } from "~/components/useSchemaSelection"; import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody"; @@ -26,18 +25,12 @@ 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.settings.discourseRelations, - plugin.settings.nodeTypes, - plugin.settings.relationTypes, - ]); + const source = { + nodeTypes: plugin.settings.nodeTypes, + relationTypes: plugin.settings.relationTypes, + relationTriples: plugin.settings.discourseRelations, + templateNames: getTemplateFiles(plugin.app), + }; const selection = useSchemaSelection({ source, From 373f43a5843771fa0c5df43498f28949addb5789 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 13:16:48 -0400 Subject: [PATCH 8/9] ENG-1976 Replace warnings return value with onWarning callback in exportSchemaSelection Removes SpecExportResult and buildSchemaExportPayload; exportSchemaSelection now returns Promise and surfaces warnings via onWarning callback, eliminating the three-hop pass-through. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/ExportSpecsModal.tsx | 19 ++--- apps/obsidian/src/utils/specExport.ts | 69 +++++++------------ 2 files changed, 30 insertions(+), 58 deletions(-) diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx index 9eed1fe8b..db2c256d4 100644 --- a/apps/obsidian/src/components/ExportSpecsModal.tsx +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -53,24 +53,17 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { } setIsExporting(true); + const warnings: string[] = []; try { - const result = await exportSchemaSelection({ + const filePath = await exportSchemaSelection({ plugin, selection: payload, + onWarning: (message) => warnings.push(message), }); - 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) { - new Notice(`Export warnings:\n${result.warnings.join("\n")}`, 6000); + new Notice(`Exported schema to ${filePath}.`, 6000); + if (warnings.length > 0) { + new Notice(`Export warnings:\n${warnings.join("\n")}`, 6000); } onClose(); diff --git a/apps/obsidian/src/utils/specExport.ts b/apps/obsidian/src/utils/specExport.ts index 5501df05e..3e9871d58 100644 --- a/apps/obsidian/src/utils/specExport.ts +++ b/apps/obsidian/src/utils/specExport.ts @@ -12,37 +12,33 @@ import { import { getTemplatePluginInfo } from "~/utils/templates"; import { saveJsonToUserLocation } from "~/utils/nativeJsonFileDialogs"; -export type SpecExportResult = { - filePath: string; - warnings: string[]; -}; - const getTemplateContents = async ({ plugin, templateNames, + onWarning, }: { plugin: DiscourseGraphPlugin; templateNames: string[]; -}): Promise<{ templates: DiscourseSchemaTemplate[]; warnings: string[] }> => { - const warnings: string[] = []; - const templates: DiscourseSchemaTemplate[] = []; + onWarning: (message: string) => void; +}): Promise => { const { isEnabled, folderPath } = getTemplatePluginInfo(plugin.app); if (!isEnabled || !folderPath) { if (templateNames.length > 0) { - warnings.push( + onWarning( "Templates plugin is not enabled or folder is not configured; template content was skipped.", ); } - return { templates, warnings }; + return []; } + const templates: DiscourseSchemaTemplate[] = []; 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`); + onWarning(`Template file not found: ${templateName}.md`); continue; } @@ -50,33 +46,26 @@ const getTemplateContents = async ({ templates.push({ name: templateName, content }); } - return { templates, warnings }; + return templates; }; -const buildSchemaExportPayload = async ({ +export const exportSchemaSelection = async ({ plugin, selection, + onWarning = () => {}, }: { plugin: DiscourseGraphPlugin; selection: SchemaSelection; -}): Promise<{ payload: DiscourseSchemaFile; warnings: string[] }> => { + onWarning?: (message: string) => void; +}): Promise => { const selectedNodeTypeIds = new Set(selection.nodeTypeIds); const selectedRelationTypeIds = new Set(selection.relationTypeIds); const selectedDiscourseRelationIds = new Set(selection.discourseRelationIds); - const selectedNodeTypes = plugin.settings.nodeTypes.filter((nt) => - selectedNodeTypeIds.has(nt.id), - ); - const selectedRelationTypes = plugin.settings.relationTypes.filter((rt) => - selectedRelationTypeIds.has(rt.id), - ); - const selectedDiscourseRelations = plugin.settings.discourseRelations.filter( - (dr) => selectedDiscourseRelationIds.has(dr.id), - ); - - const { templates, warnings } = await getTemplateContents({ + const templates = await getTemplateContents({ plugin, templateNames: selection.templateNames, + onWarning, }); const payload: DiscourseSchemaFile = { @@ -84,33 +73,23 @@ const buildSchemaExportPayload = async ({ exportedAt: new Date().toISOString(), pluginVersion: plugin.manifest.version, vaultName: plugin.app.vault.getName(), - nodeTypes: selectedNodeTypes, - relationTypes: selectedRelationTypes, - discourseRelations: selectedDiscourseRelations, + nodeTypes: plugin.settings.nodeTypes.filter((nt) => + selectedNodeTypeIds.has(nt.id), + ), + relationTypes: plugin.settings.relationTypes.filter((rt) => + selectedRelationTypeIds.has(rt.id), + ), + discourseRelations: plugin.settings.discourseRelations.filter((dr) => + selectedDiscourseRelationIds.has(dr.id), + ), templates, }; - return { payload, warnings }; -}; - -export const exportSchemaSelection = async ({ - plugin, - selection, -}: { - plugin: DiscourseGraphPlugin; - selection: SchemaSelection; -}): 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({ + return saveJsonToUserLocation({ title: "Export discourse graph schema", fileName, content: serializedPayload, }); - - return { filePath, warnings }; }; From 83e0548ebf172dda91997911c90b425188556f4f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 14:06:53 -0400 Subject: [PATCH 9/9] ENG-1976 Populate vaultId in the exported schema payload The field is part of the schema file contract defined in ENG-1975; this fills it from the vault's appId so importers can rebuild the source RID. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/specExport.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/obsidian/src/utils/specExport.ts b/apps/obsidian/src/utils/specExport.ts index 3e9871d58..0a8ab907a 100644 --- a/apps/obsidian/src/utils/specExport.ts +++ b/apps/obsidian/src/utils/specExport.ts @@ -11,6 +11,7 @@ import { } from "~/utils/specValidation"; import { getTemplatePluginInfo } from "~/utils/templates"; import { saveJsonToUserLocation } from "~/utils/nativeJsonFileDialogs"; +import { getVaultId } from "~/utils/supabaseContext"; const getTemplateContents = async ({ plugin, @@ -73,6 +74,7 @@ export const exportSchemaSelection = async ({ exportedAt: new Date().toISOString(), pluginVersion: plugin.manifest.version, vaultName: plugin.app.vault.getName(), + vaultId: getVaultId(plugin.app), nodeTypes: plugin.settings.nodeTypes.filter((nt) => selectedNodeTypeIds.has(nt.id), ),