Skip to content

Commit f364d5a

Browse files
committed
ENG-1976 Add schema export command to Obsidian
1 parent e9132f3 commit f364d5a

4 files changed

Lines changed: 296 additions & 0 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { App, Notice } from "obsidian";
2+
import { useMemo, useState } from "react";
3+
import type DiscourseGraphPlugin from "~/index";
4+
import { exportSchemaSelection } from "~/utils/specExport";
5+
import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs";
6+
import { getDgSchemaFileName } from "~/utils/specValidation";
7+
import { getTemplateFiles } from "~/utils/templates";
8+
import {
9+
getReferencedTemplateNames,
10+
useSchemaSelection,
11+
type SchemaSelectionSource,
12+
} from "~/components/useSchemaSelection";
13+
import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody";
14+
import { ReactRootModal } from "~/components/ReactRootModal";
15+
16+
type ExportSpecsModalProps = {
17+
plugin: DiscourseGraphPlugin;
18+
onClose: () => void;
19+
};
20+
21+
export const openExportSpecsModal = (plugin: DiscourseGraphPlugin): void => {
22+
new ExportSpecsModal(plugin.app, plugin).open();
23+
};
24+
25+
const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => {
26+
const [isExporting, setIsExporting] = useState(false);
27+
const outputFileName = getDgSchemaFileName(plugin.app.vault.getName());
28+
29+
const source = useMemo<SchemaSelectionSource>(() => {
30+
return {
31+
nodeTypes: plugin.settings.nodeTypes,
32+
relationTypes: plugin.settings.relationTypes,
33+
relationTriples: plugin.settings.discourseRelations,
34+
templateNames: getTemplateFiles(plugin.app),
35+
};
36+
}, [
37+
plugin.app,
38+
plugin.settings.discourseRelations,
39+
plugin.settings.nodeTypes,
40+
plugin.settings.relationTypes,
41+
]);
42+
43+
const selection = useSchemaSelection({
44+
source,
45+
resetKey: "export",
46+
initialTemplateNames: [
47+
...getReferencedTemplateNames(source.nodeTypes),
48+
].filter((name) => source.templateNames.includes(name)),
49+
});
50+
51+
const handleExport = async (): Promise<void> => {
52+
const payload = selection.asSelectionPayload();
53+
const hasSelection =
54+
payload.nodeTypeIds.length > 0 ||
55+
payload.relationTypeIds.length > 0 ||
56+
payload.relationIds.length > 0 ||
57+
payload.templateNames.length > 0;
58+
if (!hasSelection) {
59+
new Notice("Select at least one schema item or template to export.");
60+
return;
61+
}
62+
63+
setIsExporting(true);
64+
try {
65+
const result = await exportSchemaSelection({
66+
plugin,
67+
selection: {
68+
nodeTypeIds: payload.nodeTypeIds,
69+
relationTypeIds: payload.relationTypeIds,
70+
discourseRelationIds: payload.relationIds,
71+
templateNames: payload.templateNames,
72+
},
73+
});
74+
75+
const warningSuffix =
76+
result.warnings.length > 0
77+
? ` (${result.warnings.length} warning${result.warnings.length === 1 ? "" : "s"})`
78+
: "";
79+
80+
new Notice(
81+
`Exported schema to ${result.filePath}${warningSuffix}.`,
82+
6000,
83+
);
84+
85+
if (result.warnings.length > 0) {
86+
for (const warning of result.warnings) {
87+
new Notice(warning, 6000);
88+
}
89+
}
90+
91+
onClose();
92+
} catch (error) {
93+
if (error instanceof NativeFileDialogCancelledError) {
94+
return;
95+
}
96+
console.error("Failed to export schema:", error);
97+
const message = error instanceof Error ? error.message : String(error);
98+
new Notice(`Schema export failed: ${message}`, 6000);
99+
} finally {
100+
setIsExporting(false);
101+
}
102+
};
103+
104+
return (
105+
<SchemaSelectionModalBody
106+
title="Export discourse graph schema"
107+
description={`Select the node types, relation types, relation triples, and templates to include in ${outputFileName}.`}
108+
source={source}
109+
selection={selection}
110+
emptyTemplateText="No templates found in your Templates folder."
111+
onDependencyViolation={(message) => new Notice(message)}
112+
footerSecondaryLabel="Cancel"
113+
onFooterSecondaryClick={onClose}
114+
footerPrimaryLabel={isExporting ? "Exporting..." : "Export schema"}
115+
onFooterPrimaryClick={() => void handleExport()}
116+
isFooterPrimaryDisabled={isExporting}
117+
/>
118+
);
119+
};
120+
121+
export class ExportSpecsModal extends ReactRootModal {
122+
private plugin: DiscourseGraphPlugin;
123+
124+
constructor(app: App, plugin: DiscourseGraphPlugin) {
125+
super(app);
126+
this.plugin = plugin;
127+
}
128+
129+
protected renderContent() {
130+
return (
131+
<ExportSpecsContent plugin={this.plugin} onClose={() => this.close()} />
132+
);
133+
}
134+
}

apps/obsidian/src/components/GeneralSettings.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { usePlugin } from "./PluginContext";
33
import { setIcon } from "obsidian";
44
import SuggestInput from "./SuggestInput";
55
import { DiscourseGraphLogoIcon, SlackLogoIcon } from "./Icons";
6+
import { openExportSpecsModal } from "./ExportSpecsModal";
7+
import { getDgSchemaFileName } from "~/utils/specValidation";
68

79
const DOCS_URL = "https://discoursegraphs.com/docs/obsidian";
810
const COMMUNITY_URL =
@@ -148,6 +150,7 @@ const GeneralSettings = () => {
148150
const [nodeTagHotkey, setNodeTagHotkey] = useState<string>(
149151
plugin.settings.nodeTagHotkey,
150152
);
153+
const schemaFileName = getDgSchemaFileName(plugin.app.vault.getName());
151154

152155
const handleToggleChange = (newValue: boolean) => {
153156
setShowIdsInFrontmatter(newValue);
@@ -298,6 +301,25 @@ const GeneralSettings = () => {
298301
</div>
299302
</div>
300303

304+
<div className="setting-item">
305+
<div className="setting-item-info">
306+
<div className="setting-item-name">Export discourse graph schema</div>
307+
<div className="setting-item-description">
308+
Export selected node types, relation types, relation triples, and
309+
templates to a JSON file named <code>{schemaFileName}</code>.
310+
</div>
311+
</div>
312+
<div className="setting-item-control">
313+
<button
314+
type="button"
315+
className="rounded border px-3 py-1.5 text-sm"
316+
onClick={() => void openExportSpecsModal(plugin)}
317+
>
318+
Open export modal
319+
</button>
320+
</div>
321+
</div>
322+
301323
<InfoSection />
302324
</div>
303325
);

apps/obsidian/src/utils/registerCommands.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { NodeTypeModal } from "~/components/NodeTypeModal";
44
import ModifyNodeModal from "~/components/ModifyNodeModal";
55
import { BulkIdentifyDiscourseNodesModal } from "~/components/BulkIdentifyDiscourseNodesModal";
66
import { ImportNodesModal } from "~/components/ImportNodesModal";
7+
import { openExportSpecsModal } from "~/components/ExportSpecsModal";
78
import { convertPageToDiscourseNode, createDiscourseNode } from "./createNode";
89
import { refreshAllImportedFiles } from "./importNodes";
910
import { VIEW_TYPE_MARKDOWN, VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants";
@@ -194,6 +195,14 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => {
194195
},
195196
});
196197

198+
plugin.addCommand({
199+
id: "export-dg-schema",
200+
name: "Export discourse graph schema",
201+
callback: () => {
202+
openExportSpecsModal(plugin);
203+
},
204+
});
205+
197206
plugin.addCommand({
198207
id: "toggle-discourse-context",
199208
name: "Toggle discourse context",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { TFile } from "obsidian";
2+
import type DiscourseGraphPlugin from "~/index";
3+
import type {
4+
DiscourseNode,
5+
DiscourseRelation,
6+
DiscourseSchemaFile,
7+
DiscourseSchemaTemplate,
8+
} from "~/types";
9+
import {
10+
DG_SCHEMA_EXPORT_VERSION,
11+
getDgSchemaFileName,
12+
} from "~/utils/specValidation";
13+
import { getTemplatePluginInfo } from "~/utils/templates";
14+
import { saveJsonToUserLocation } from "~/utils/nativeJsonFileDialogs";
15+
16+
export type SpecExportSelection = {
17+
nodeTypeIds: string[];
18+
relationTypeIds: string[];
19+
discourseRelationIds: string[];
20+
templateNames: string[];
21+
};
22+
23+
export type SpecExportResult = {
24+
filePath: string;
25+
warnings: string[];
26+
};
27+
28+
const asMap = <T extends { id: string }>(items: T[]): Map<string, T> => {
29+
return new Map(items.map((item) => [item.id, item]));
30+
};
31+
32+
const getTemplateContents = async ({
33+
plugin,
34+
templateNames,
35+
}: {
36+
plugin: DiscourseGraphPlugin;
37+
templateNames: string[];
38+
}): Promise<{ templates: DiscourseSchemaTemplate[]; warnings: string[] }> => {
39+
const warnings: string[] = [];
40+
const templates: DiscourseSchemaTemplate[] = [];
41+
const { isEnabled, folderPath } = getTemplatePluginInfo(plugin.app);
42+
43+
if (!isEnabled || !folderPath) {
44+
if (templateNames.length > 0) {
45+
warnings.push(
46+
"Templates plugin is not enabled or folder is not configured; template content was skipped.",
47+
);
48+
}
49+
return { templates, warnings };
50+
}
51+
52+
for (const templateName of templateNames) {
53+
const templatePath = `${folderPath}/${templateName}.md`;
54+
const templateFile = plugin.app.vault.getAbstractFileByPath(templatePath);
55+
56+
if (!(templateFile instanceof TFile)) {
57+
warnings.push(`Template file not found: ${templateName}.md`);
58+
continue;
59+
}
60+
61+
const content = await plugin.app.vault.read(templateFile);
62+
templates.push({ name: templateName, content });
63+
}
64+
65+
return { templates, warnings };
66+
};
67+
68+
const buildSchemaExportPayload = async ({
69+
plugin,
70+
selection,
71+
}: {
72+
plugin: DiscourseGraphPlugin;
73+
selection: SpecExportSelection;
74+
}): Promise<{ payload: DiscourseSchemaFile; warnings: string[] }> => {
75+
const nodeTypeMap = asMap(plugin.settings.nodeTypes);
76+
const relationTypeMap = asMap(plugin.settings.relationTypes);
77+
const discourseRelationMap = asMap(plugin.settings.discourseRelations);
78+
79+
const selectedNodeTypes: DiscourseNode[] = selection.nodeTypeIds
80+
.map((id) => nodeTypeMap.get(id))
81+
.filter((nodeType): nodeType is DiscourseNode => !!nodeType);
82+
83+
const selectedRelationTypes = selection.relationTypeIds
84+
.map((id) => relationTypeMap.get(id))
85+
.filter((relationType) => !!relationType);
86+
87+
const selectedDiscourseRelations: DiscourseRelation[] =
88+
selection.discourseRelationIds
89+
.map((id) => discourseRelationMap.get(id))
90+
.filter((relation): relation is DiscourseRelation => !!relation);
91+
92+
const { templates, warnings } = await getTemplateContents({
93+
plugin,
94+
templateNames: selection.templateNames,
95+
});
96+
97+
const payload: DiscourseSchemaFile = {
98+
version: DG_SCHEMA_EXPORT_VERSION,
99+
exportedAt: new Date().toISOString(),
100+
pluginVersion: plugin.manifest.version,
101+
vaultName: plugin.app.vault.getName(),
102+
nodeTypes: selectedNodeTypes,
103+
relationTypes: selectedRelationTypes,
104+
discourseRelations: selectedDiscourseRelations,
105+
templates,
106+
};
107+
108+
return { payload, warnings };
109+
};
110+
111+
export const exportSchemaSelection = async ({
112+
plugin,
113+
selection,
114+
}: {
115+
plugin: DiscourseGraphPlugin;
116+
selection: SpecExportSelection;
117+
}): Promise<SpecExportResult> => {
118+
const { payload, warnings } = await buildSchemaExportPayload({
119+
plugin,
120+
selection,
121+
});
122+
const serializedPayload = JSON.stringify(payload, null, 2);
123+
const fileName = getDgSchemaFileName(plugin.app.vault.getName());
124+
const filePath = await saveJsonToUserLocation({
125+
title: "Export discourse graph schema",
126+
fileName,
127+
content: serializedPayload,
128+
});
129+
130+
return { filePath, warnings };
131+
};

0 commit comments

Comments
 (0)