Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ce646c8
ENG-1975 Add schema file contract and shared foundation for Obsidian …
trangdoan982 Jun 30, 2026
bbefacd
ENG-1975 Add ReactRootModal and useSchemaSelection to shared foundation
trangdoan982 Jul 29, 2026
ec28a89
ENG-1975 Fix color enum validation and add passthrough for forward co…
trangdoan982 Jul 29, 2026
93051ea
ENG-1975 Move nativeJsonFileDialogs to shared foundation (used by bot…
trangdoan982 Jul 29, 2026
050f122
ENG-1975 Address review: remove ReactRootModal, pin Zod schemas to TS…
trangdoan982 Jul 29, 2026
ada2e34
ENG-1975 Move useSchemaSelection to ENG-2083 (belongs with selection …
trangdoan982 Jul 29, 2026
0c85b0f
ENG-1975 Add SchemaSelection to shared types (reused by export and im…
trangdoan982 Jul 30, 2026
fa29a9e
ENG-1975 Record exporting vault's appId in the schema file contract
trangdoan982 Jul 30, 2026
a2367b5
ENG-2083 Add schema selection panel UI for Obsidian export/import
trangdoan982 Jul 29, 2026
647cdca
ENG-2083 Move useSchemaSelection here from foundation (UI state belon…
trangdoan982 Jul 29, 2026
ace9557
remove verbose
trangdoan982 Jul 30, 2026
437c314
ENG-2083 Remove emptyTemplateText/beforePanel/afterPanel props; fix t…
trangdoan982 Jul 30, 2026
07ccd44
ENG-2083 Use SchemaSelection type from ~/types; rename relationIds → …
trangdoan982 Jul 30, 2026
c64b70d
ENG-1976 Add schema export command to Obsidian
trangdoan982 Jul 29, 2026
b437189
ENG-1976 Inline Modal boilerplate in ExportSpecsModal (remove ReactRo…
trangdoan982 Jul 29, 2026
e97671b
ENG-1976 Remove console.error (violates Obsidian plugin guidelines)
trangdoan982 Jul 30, 2026
fbf3e3f
ENG-1976 Remove emptyTemplateText prop from ExportSpecsModal call site
trangdoan982 Jul 30, 2026
b8aac3e
ENG-1976 Address review: use SchemaSelection type, simplify ExportSpe…
trangdoan982 Jul 30, 2026
e9917e0
ENG-1976 Simplify buildSchemaExportPayload: filter settings arrays di…
trangdoan982 Jul 30, 2026
944257b
ENG-1976 Remove useMemo from source — settings are stable for modal l…
trangdoan982 Jul 30, 2026
373f43a
ENG-1976 Replace warnings return value with onWarning callback in exp…
trangdoan982 Jul 30, 2026
83e0548
ENG-1976 Populate vaultId in the exported schema payload
trangdoan982 Jul 30, 2026
a5825fd
Merge origin/main into eng-1976-add-schema-export-command-to-obsidian
trangdoan982 Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions apps/obsidian/src/components/ExportSpecsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Modal, Notice } from "obsidian";
import { StrictMode, 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";
import { getDgSchemaFileName } from "~/utils/specValidation";
import { getTemplateFiles } from "~/utils/templates";
import {
getReferencedTemplateNames,
useSchemaSelection,
} from "~/components/useSchemaSelection";
import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody";

type ExportSpecsModalProps = {
plugin: DiscourseGraphPlugin;
onClose: () => void;
};

export const openExportSpecsModal = (plugin: DiscourseGraphPlugin): void => {
new ExportSpecsModal(plugin).open();
};

const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => {
const [isExporting, setIsExporting] = useState(false);
const outputFileName = getDgSchemaFileName(plugin.app.vault.getName());

const source = {
nodeTypes: plugin.settings.nodeTypes,
relationTypes: plugin.settings.relationTypes,
relationTriples: plugin.settings.discourseRelations,
templateNames: getTemplateFiles(plugin.app),
};

const selection = useSchemaSelection({
source,
resetKey: "export",
initialTemplateNames: [
...getReferencedTemplateNames(source.nodeTypes),
].filter((name) => source.templateNames.includes(name)),
});

const handleExport = async (): Promise<void> => {
const payload = selection.asSelectionPayload();
const hasSelection =
payload.nodeTypeIds.length > 0 ||
payload.relationTypeIds.length > 0 ||
payload.discourseRelationIds.length > 0 ||
payload.templateNames.length > 0;
if (!hasSelection) {
new Notice("Select at least one schema item or template to export.");
return;
}

setIsExporting(true);
const warnings: string[] = [];
try {
const filePath = await exportSchemaSelection({
plugin,
selection: payload,
onWarning: (message) => warnings.push(message),
});

new Notice(`Exported schema to ${filePath}.`, 6000);
if (warnings.length > 0) {
new Notice(`Export warnings:\n${warnings.join("\n")}`, 6000);
}

onClose();
} catch (error) {
if (error instanceof NativeFileDialogCancelledError) {
return;
}
const message = error instanceof Error ? error.message : String(error);
new Notice(`Schema export failed: ${message}`, 6000);
} finally {
setIsExporting(false);
}
};

return (
<SchemaSelectionModalBody
title="Export discourse graph schema"
description={`Select the node types, relation types, relation triples, and templates to include in ${outputFileName}.`}
source={source}
selection={selection}
footerSecondaryLabel="Cancel"
onFooterSecondaryClick={onClose}
footerPrimaryLabel={isExporting ? "Exporting..." : "Export schema"}
onFooterPrimaryClick={() => void handleExport()}
isFooterPrimaryDisabled={isExporting}
/>
);
};

export class ExportSpecsModal extends Modal {
private plugin: DiscourseGraphPlugin;
private root: Root | null = null;

constructor(plugin: DiscourseGraphPlugin) {
super(plugin.app);
this.plugin = plugin;
}

onOpen(): void {
this.contentEl.empty();
this.root = createRoot(this.contentEl);
this.root.render(
<StrictMode>
<ExportSpecsContent plugin={this.plugin} onClose={() => this.close()} />
</StrictMode>,
);
}

onClose(): void {
if (this.root) {
this.root.unmount();
this.root = null;
}
}
}
21 changes: 21 additions & 0 deletions apps/obsidian/src/components/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
import { FeedbackModal } from "./FeedbackModal";
import { DOCS_URL, COMMUNITY_URL } from "~/constants";

Expand Down Expand Up @@ -194,6 +196,7 @@ const GeneralSettings = () => {
const [nodeTagHotkey, setNodeTagHotkey] = useState<string>(
plugin.settings.nodeTagHotkey,
);
const schemaFileName = getDgSchemaFileName(plugin.app.vault.getName());
const [showHelpMenuStatusBarIcon, setShowHelpMenuStatusBarIcon] = useState(
plugin.settings.showHelpMenuStatusBarIcon,
);
Expand Down Expand Up @@ -343,6 +346,24 @@ const GeneralSettings = () => {
</div>
</div>

<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Export discourse graph schema</div>
<div className="setting-item-description">
Export selected node types, relation types, relation triples, and
templates to a JSON file named <code>{schemaFileName}</code>.
</div>
</div>
<div className="setting-item-control">
<button
type="button"
className="rounded border px-3 py-1.5 text-sm"
onClick={() => openExportSpecsModal(plugin)}
>
Open export modal
</button>
</div>
</div>
<ToggleSetting
name="Show help menu icon in status bar"
description="Adds a Discourse Graph icon to the status bar that opens a menu with feedback, docs, community, and settings links."
Expand Down
7 changes: 7 additions & 0 deletions apps/obsidian/src/utils/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ModifyNodeModal from "~/components/ModifyNodeModal";
import { BulkIdentifyDiscourseNodesModal } from "~/components/BulkIdentifyDiscourseNodesModal";
import { NodeSearchModal } from "~/components/NodeSearchModal";
import { ImportNodesModal } from "~/components/ImportNodesModal";
import { openExportSpecsModal } from "~/components/ExportSpecsModal";
import { FeedbackModal } from "~/components/FeedbackModal";
import { convertPageToDiscourseNode, createDiscourseNode } from "./createNode";
import { refreshAllImportedFiles } from "./importNodes";
Expand Down Expand Up @@ -205,6 +206,12 @@ 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",
Expand Down
97 changes: 97 additions & 0 deletions apps/obsidian/src/utils/specExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { TFile } from "obsidian";
import type DiscourseGraphPlugin from "~/index";
import type {
DiscourseSchemaFile,
DiscourseSchemaTemplate,
SchemaSelection,
} from "~/types";
import {
DG_SCHEMA_EXPORT_VERSION,
getDgSchemaFileName,
} from "~/utils/specValidation";
import { getTemplatePluginInfo } from "~/utils/templates";
import { saveJsonToUserLocation } from "~/utils/nativeJsonFileDialogs";
import { getVaultId } from "~/utils/supabaseContext";

const getTemplateContents = async ({
plugin,
templateNames,
onWarning,
}: {
plugin: DiscourseGraphPlugin;
templateNames: string[];
onWarning: (message: string) => void;
}): Promise<DiscourseSchemaTemplate[]> => {
const { isEnabled, folderPath } = getTemplatePluginInfo(plugin.app);

if (!isEnabled || !folderPath) {
if (templateNames.length > 0) {
onWarning(
"Templates plugin is not enabled or folder is not configured; template content was skipped.",
);
}
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)) {
onWarning(`Template file not found: ${templateName}.md`);
continue;
}

const content = await plugin.app.vault.read(templateFile);
templates.push({ name: templateName, content });
}

return templates;
};

export const exportSchemaSelection = async ({
plugin,
selection,
onWarning = () => {},
}: {
plugin: DiscourseGraphPlugin;
selection: SchemaSelection;
onWarning?: (message: string) => void;
}): Promise<string> => {
const selectedNodeTypeIds = new Set(selection.nodeTypeIds);
const selectedRelationTypeIds = new Set(selection.relationTypeIds);
const selectedDiscourseRelationIds = new Set(selection.discourseRelationIds);

const templates = await getTemplateContents({
plugin,
templateNames: selection.templateNames,
onWarning,
});

const payload: DiscourseSchemaFile = {
version: DG_SCHEMA_EXPORT_VERSION,
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),
),
relationTypes: plugin.settings.relationTypes.filter((rt) =>
selectedRelationTypeIds.has(rt.id),
),
discourseRelations: plugin.settings.discourseRelations.filter((dr) =>
selectedDiscourseRelationIds.has(dr.id),
),
templates,
};

const serializedPayload = JSON.stringify(payload, null, 2);
const fileName = getDgSchemaFileName(plugin.app.vault.getName());
return saveJsonToUserLocation({
title: "Export discourse graph schema",
fileName,
content: serializedPayload,
});
};