From ce646c87ddcb20fa6b0298ce22dfd30f5d285969 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 30 Jun 2026 18:26:14 -0400 Subject: [PATCH 01/22] ENG-1975 Add schema file contract and shared foundation for Obsidian export/import. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DiscourseSchemaFile and DiscourseSchemaTemplate type definitions, plus getDgSchemaFileName and DG_SCHEMA_EXPORT_VERSION — the minimal shared primitives needed by both the schema export (ENG-1976) and import (ENG-1977) features. Co-authored-by: Cursor --- apps/obsidian/src/types.ts | 16 +++++ apps/obsidian/src/utils/specValidation.ts | 83 +++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 apps/obsidian/src/utils/specValidation.ts 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/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 bbefacd900951434bde1f21bf1f17eff06b12a62 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:38:50 -0400 Subject: [PATCH 02/22] ENG-1975 Add ReactRootModal and useSchemaSelection to shared foundation --- .../src/components/ReactRootModal.tsx | 27 ++ .../src/components/useSchemaSelection.ts | 242 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 apps/obsidian/src/components/ReactRootModal.tsx create mode 100644 apps/obsidian/src/components/useSchemaSelection.ts 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/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], + }), + }; +}; From ec28a891d195de717ab8292cc1a0bb34ce6d7052 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:53:41 -0400 Subject: [PATCH 03/22] ENG-1975 Fix color enum validation and add passthrough for forward compatibility --- apps/obsidian/src/utils/specValidation.ts | 112 ++++++++++++---------- 1 file changed, 60 insertions(+), 52 deletions(-) diff --git a/apps/obsidian/src/utils/specValidation.ts b/apps/obsidian/src/utils/specValidation.ts index b9662e548..47e399916 100644 --- a/apps/obsidian/src/utils/specValidation.ts +++ b/apps/obsidian/src/utils/specValidation.ts @@ -1,66 +1,74 @@ import { z } from "zod"; import type { DiscourseSchemaFile } from "~/types"; +import { TLDRAW_COLOR_NAMES } from "~/utils/tldrawColors"; 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 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(), + }) + .passthrough(); 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 discourseRelationTypeSchema = z + .object({ + id: z.string(), + label: z.string(), + complement: z.string(), + color: z.enum(TLDRAW_COLOR_NAMES), + created: z.number(), + modified: z.number(), + importedFromRid: z.string().optional(), + status: relationImportStatusSchema.optional(), + authorId: z.number().optional(), + }) + .passthrough(); -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 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(), + }) + .passthrough(); -const templateExportSchema = z.object({ - name: z.string(), - content: z.string(), -}); +const templateExportSchema = z + .object({ name: z.string(), content: z.string() }) + .passthrough(); -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), -}); +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), + }) + .passthrough(); const normalizeToKebabCase = (value: string): string => { return value From 93051ea80ca28e206693d195352ed3f7b0746cdf Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:58:10 -0400 Subject: [PATCH 04/22] ENG-1975 Move nativeJsonFileDialogs to shared foundation (used by both export and import) --- .../src/utils/nativeJsonFileDialogs.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 apps/obsidian/src/utils/nativeJsonFileDialogs.ts 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 }; +}; From 050f122f4a7593660e8ed85167c08a801c25ffea Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 15:31:39 -0400 Subject: [PATCH 05/22] ENG-1975 Address review: remove ReactRootModal, pin Zod schemas to TS types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete ReactRootModal.tsx (no callers yet; DRY savings too small to justify the abstraction layer) - Annotate each sub-schema with z.ZodType so TypeScript verifies schema coverage against the authoritative types in types.ts at compile time - Drop the `as DiscourseSchemaFile` cast from parseDgSchemaFile — no longer needed once dgSchemaFileSchema is typed Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/ReactRootModal.tsx | 27 ------------------- apps/obsidian/src/utils/specValidation.ts | 20 +++++++++----- 2 files changed, 13 insertions(+), 34 deletions(-) delete mode 100644 apps/obsidian/src/components/ReactRootModal.tsx diff --git a/apps/obsidian/src/components/ReactRootModal.tsx b/apps/obsidian/src/components/ReactRootModal.tsx deleted file mode 100644 index 566df4d77..000000000 --- a/apps/obsidian/src/components/ReactRootModal.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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/utils/specValidation.ts b/apps/obsidian/src/utils/specValidation.ts index 47e399916..25134b806 100644 --- a/apps/obsidian/src/utils/specValidation.ts +++ b/apps/obsidian/src/utils/specValidation.ts @@ -1,10 +1,16 @@ import { z } from "zod"; -import type { DiscourseSchemaFile } from "~/types"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, + DiscourseSchemaFile, + DiscourseSchemaTemplate, +} from "~/types"; import { TLDRAW_COLOR_NAMES } from "~/utils/tldrawColors"; export const DG_SCHEMA_EXPORT_VERSION = 1; -const discourseNodeSchema = z +const discourseNodeSchema: z.ZodType = z .object({ id: z.string(), name: z.string(), @@ -25,7 +31,7 @@ const discourseNodeSchema = z const relationImportStatusSchema = z.enum(["provisional", "accepted"]); -const discourseRelationTypeSchema = z +const discourseRelationTypeSchema: z.ZodType = z .object({ id: z.string(), label: z.string(), @@ -39,7 +45,7 @@ const discourseRelationTypeSchema = z }) .passthrough(); -const discourseRelationSchema = z +const discourseRelationSchema: z.ZodType = z .object({ id: z.string(), sourceId: z.string(), @@ -53,11 +59,11 @@ const discourseRelationSchema = z }) .passthrough(); -const templateExportSchema = z +const templateExportSchema: z.ZodType = z .object({ name: z.string(), content: z.string() }) .passthrough(); -export const dgSchemaFileSchema = z +export const dgSchemaFileSchema: z.ZodType = z .object({ version: z.literal(DG_SCHEMA_EXPORT_VERSION), exportedAt: z.string(), @@ -87,5 +93,5 @@ export const getDgSchemaFileName = (vaultName?: string): string => { }; export const parseDgSchemaFile = (value: unknown): DiscourseSchemaFile => { - return dgSchemaFileSchema.parse(value) as DiscourseSchemaFile; + return dgSchemaFileSchema.parse(value); }; From ada2e342ee400a5fc730982105714dda044bd07f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 15:41:20 -0400 Subject: [PATCH 06/22] ENG-1975 Move useSchemaSelection to ENG-2083 (belongs with selection panel UI, not foundation) --- .../src/components/useSchemaSelection.ts | 242 ------------------ 1 file changed, 242 deletions(-) delete mode 100644 apps/obsidian/src/components/useSchemaSelection.ts diff --git a/apps/obsidian/src/components/useSchemaSelection.ts b/apps/obsidian/src/components/useSchemaSelection.ts deleted file mode 100644 index 319f6047f..000000000 --- a/apps/obsidian/src/components/useSchemaSelection.ts +++ /dev/null @@ -1,242 +0,0 @@ -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], - }), - }; -}; From 0c85b0f86abd32d7597d5fab7e34980f06b66790 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 12:49:40 -0400 Subject: [PATCH 07/22] ENG-1975 Add SchemaSelection to shared types (reused by export and import) Co-Authored-By: Claude Sonnet 4.6 --- apps/obsidian/src/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index a43ba9ae3..0bb5d8869 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -133,4 +133,11 @@ export type DiscourseSchemaFile = { templates: DiscourseSchemaTemplate[]; }; +export type SchemaSelection = { + nodeTypeIds: string[]; + relationTypeIds: string[]; + discourseRelationIds: string[]; + templateNames: string[]; +}; + export const VIEW_TYPE_DISCOURSE_CONTEXT = "discourse-context-view"; From fa29a9ebab32818bc091962efcdd09bc2068413b Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 14:05:56 -0400 Subject: [PATCH 08/22] ENG-1975 Record exporting vault's appId in the schema file contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vaultName is not unique — two vaults can share a name — so it cannot identify the source space. Recording vaultId (the Obsidian appId) lets an importer rebuild the source RID as orn:obsidian.schema:/, which is what the existing Supabase import path already generates. Schema imported from a file and content imported from that same vault over Supabase then resolve to the same importedFromRid. Required rather than optional: the export command that produces these files has not shipped, so no version 1 files exist to stay compatible with. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/types.ts | 2 ++ apps/obsidian/src/utils/specValidation.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index 0bb5d8869..ef4ea1256 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -127,6 +127,8 @@ export type DiscourseSchemaFile = { exportedAt: string; pluginVersion: string; vaultName: string; + /** Obsidian appId of the exporting vault; lets importers rebuild the source RID. */ + vaultId: string; nodeTypes: DiscourseNode[]; relationTypes: DiscourseRelationType[]; discourseRelations: DiscourseRelation[]; diff --git a/apps/obsidian/src/utils/specValidation.ts b/apps/obsidian/src/utils/specValidation.ts index 25134b806..09f96f7e7 100644 --- a/apps/obsidian/src/utils/specValidation.ts +++ b/apps/obsidian/src/utils/specValidation.ts @@ -69,6 +69,7 @@ export const dgSchemaFileSchema: z.ZodType = z exportedAt: z.string(), pluginVersion: z.string(), vaultName: z.string(), + vaultId: z.string(), nodeTypes: z.array(discourseNodeSchema), relationTypes: z.array(discourseRelationTypeSchema), discourseRelations: z.array(discourseRelationSchema), From a2367b5333f7f2a92df61acb6a87e8cf89bf6cd8 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:39:03 -0400 Subject: [PATCH 09/22] ENG-2083 Add schema selection panel UI for Obsidian export/import --- .../components/SchemaSelectionModalBody.tsx | 77 +++++ .../src/components/SchemaSelectionPanel.tsx | 307 ++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 apps/obsidian/src/components/SchemaSelectionModalBody.tsx create mode 100644 apps/obsidian/src/components/SchemaSelectionPanel.tsx 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) => ( + + ))} +
+ )} +
+
+ + ); +}; From 647cdca1135ab71de79b8efc76cf81291fe6ef4e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 15:42:34 -0400 Subject: [PATCH 10/22] ENG-2083 Move useSchemaSelection here from foundation (UI state belongs with selection panel) --- .../src/components/useSchemaSelection.ts | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 apps/obsidian/src/components/useSchemaSelection.ts 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], + }), + }; +}; From ace9557764ec80ec457205213de4e7e381b2f4e6 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 22:56:03 -0400 Subject: [PATCH 11/22] remove verbose --- apps/obsidian/src/components/SchemaSelectionPanel.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx index fd113dce2..dc2c8b729 100644 --- a/apps/obsidian/src/components/SchemaSelectionPanel.tsx +++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx @@ -292,8 +292,7 @@ export const SchemaSelectionPanel = ({ used by{" "} {(templateToNodeTypeNames.get(templateName) ?? []).join( ", ", - )}{" "} - type + )} )} From 437c3149591fd6a3caa3daf4f6d4261e2b5418c2 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 23:15:28 -0400 Subject: [PATCH 12/22] ENG-2083 Remove emptyTemplateText/beforePanel/afterPanel props; fix templateToNodeTypeNames double-pass - Remove `emptyTemplateText` prop from SchemaSelectionPanel and SchemaSelectionModalBody; hardcode "No template files found." - Remove `beforePanel` and `afterPanel` props from SchemaSelectionModalBody (afterPanel had no callers; beforePanel is now composed as a sibling at the call site) - Collapse templateToNodeTypeNames two-pass sort into a single sorted source array + single map-build pass Co-Authored-By: Claude Sonnet 4.6 --- .../components/SchemaSelectionModalBody.tsx | 12 --------- .../src/components/SchemaSelectionPanel.tsx | 26 ++++++------------- 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/apps/obsidian/src/components/SchemaSelectionModalBody.tsx b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx index 6a37e440a..f4b977423 100644 --- a/apps/obsidian/src/components/SchemaSelectionModalBody.tsx +++ b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx @@ -1,5 +1,4 @@ import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; -import type { ReactNode } from "react"; import type { SchemaSelectionSource, SchemaSelectionState, @@ -10,10 +9,7 @@ type SchemaSelectionModalBodyProps = { description: string; source: SchemaSelectionSource; selection: SchemaSelectionState; - emptyTemplateText: string; onDependencyViolation?: (message: string) => void; - beforePanel?: ReactNode; - afterPanel?: ReactNode; footerSecondaryLabel: string; onFooterSecondaryClick: () => void; footerPrimaryLabel: string; @@ -27,10 +23,7 @@ export const SchemaSelectionModalBody = ({ description, source, selection, - emptyTemplateText, onDependencyViolation, - beforePanel, - afterPanel, footerSecondaryLabel, onFooterSecondaryClick, footerPrimaryLabel, @@ -43,17 +36,12 @@ export const SchemaSelectionModalBody = ({

{title}

{description}

- {beforePanel} - - {afterPanel} -
{source.templateNames.length === 0 ? ( -

{emptyTemplateText}

+

No template files found.

) : (
{source.templateNames.map((templateName) => ( From 07ccd443e42a8c1ebaef024a29a379faf3f695ea Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 12:50:13 -0400 Subject: [PATCH 13/22] =?UTF-8?q?ENG-2083=20Use=20SchemaSelection=20type?= =?UTF-8?q?=20from=20~/types;=20rename=20relationIds=20=E2=86=92=20discour?= =?UTF-8?q?seRelationIds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- apps/obsidian/src/components/useSchemaSelection.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/components/useSchemaSelection.ts b/apps/obsidian/src/components/useSchemaSelection.ts index 319f6047f..ebeb59710 100644 --- a/apps/obsidian/src/components/useSchemaSelection.ts +++ b/apps/obsidian/src/components/useSchemaSelection.ts @@ -3,6 +3,7 @@ import type { DiscourseNode, DiscourseRelation, DiscourseRelationType, + SchemaSelection, } from "~/types"; export type SchemaSelectionSource = { @@ -45,12 +46,7 @@ export type SchemaSelectionState = { selectAllTemplates: () => void; deselectAllTemplates: () => void; toggleTemplate: (templateName: string, shouldSelect: boolean) => void; - asSelectionPayload: () => { - nodeTypeIds: string[]; - relationTypeIds: string[]; - relationIds: string[]; - templateNames: string[]; - }; + asSelectionPayload: () => SchemaSelection; }; const updateSet = ( @@ -235,7 +231,7 @@ export const useSchemaSelection = ({ asSelectionPayload: () => ({ nodeTypeIds: [...selectedNodeTypeIds], relationTypeIds: [...selectedRelationTypeIds], - relationIds: [...selectedRelationIds], + discourseRelationIds: [...selectedRelationIds], templateNames: [...selectedTemplateNames], }), }; From c64b70d47df11bc71dca6c9edafea240e889b20b Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:39:14 -0400 Subject: [PATCH 14/22] 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 15/22] 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 16/22] 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 17/22] 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 18/22] 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 19/22] 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 20/22] =?UTF-8?q?ENG-1976=20Remove=20useMemo=20from=20sour?= =?UTF-8?q?ce=20=E2=80=94=20settings=20are=20stable=20for=20modal=20lifeti?= =?UTF-8?q?me?= 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 21/22] 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 22/22] 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), ),