From ce646c87ddcb20fa6b0298ce22dfd30f5d285969 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 30 Jun 2026 18:26:14 -0400 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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),