Skip to content

Commit 3af9adf

Browse files
committed
ENG-1976 Add schema export command to Obsidian
1 parent 1e624f3 commit 3af9adf

5 files changed

Lines changed: 417 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
);
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
type SaveDialogResult = {
2+
canceled: boolean;
3+
filePath?: string;
4+
};
5+
6+
type OpenDialogResult = {
7+
canceled: boolean;
8+
filePaths: string[];
9+
};
10+
11+
type ElectronDialog = {
12+
showSaveDialog: (options: {
13+
title: string;
14+
defaultPath: string;
15+
filters: Array<{ name: string; extensions: string[] }>;
16+
}) => Promise<SaveDialogResult>;
17+
showOpenDialog: (options: {
18+
title: string;
19+
properties: string[];
20+
filters: Array<{ name: string; extensions: string[] }>;
21+
}) => Promise<OpenDialogResult>;
22+
};
23+
24+
type ElectronLike = {
25+
dialog?: ElectronDialog;
26+
remote?: {
27+
dialog?: ElectronDialog;
28+
};
29+
};
30+
31+
type FsPromisesLike = {
32+
readFile: (path: string, encoding: string) => Promise<string>;
33+
writeFile: (path: string, data: string, encoding: string) => Promise<void>;
34+
};
35+
36+
type ElectronWindow = Window & {
37+
require: (name: string) => unknown;
38+
};
39+
40+
export class NativeFileDialogCancelledError extends Error {
41+
constructor() {
42+
super("File dialog cancelled");
43+
this.name = "NativeFileDialogCancelledError";
44+
}
45+
}
46+
47+
const getElectronWindow = (): ElectronWindow => {
48+
if (typeof window === "undefined" || !("require" in window)) {
49+
throw new Error(
50+
"Schema export/import requires Obsidian desktop (Electron).",
51+
);
52+
}
53+
return window as ElectronWindow;
54+
};
55+
56+
const getFsPromises = (electronWindow: ElectronWindow): FsPromisesLike => {
57+
const fsPromises = electronWindow.require("fs/promises");
58+
if (
59+
typeof fsPromises !== "object" ||
60+
fsPromises === null ||
61+
!("readFile" in fsPromises) ||
62+
!("writeFile" in fsPromises)
63+
) {
64+
throw new Error("Unable to access filesystem read/write APIs.");
65+
}
66+
return fsPromises as FsPromisesLike;
67+
};
68+
69+
const getElectronDialog = (electronWindow: ElectronWindow): ElectronDialog => {
70+
const electron = electronWindow.require("electron") as ElectronLike;
71+
const dialog = electron.dialog ?? electron.remote?.dialog;
72+
if (!dialog?.showSaveDialog || !dialog.showOpenDialog) {
73+
throw new Error("Unable to access Electron file dialogs.");
74+
}
75+
return dialog;
76+
};
77+
78+
export const saveJsonToUserLocation = async ({
79+
title,
80+
fileName,
81+
content,
82+
}: {
83+
title: string;
84+
fileName: string;
85+
content: string;
86+
}): Promise<string> => {
87+
const electronWindow = getElectronWindow();
88+
const dialog = getElectronDialog(electronWindow);
89+
const result = await dialog.showSaveDialog({
90+
title,
91+
defaultPath: fileName,
92+
filters: [{ name: "JSON files", extensions: ["json"] }],
93+
});
94+
if (result.canceled || !result.filePath) {
95+
throw new NativeFileDialogCancelledError();
96+
}
97+
const fsPromises = getFsPromises(electronWindow);
98+
await fsPromises.writeFile(result.filePath, content, "utf8");
99+
return result.filePath;
100+
};
101+
102+
export const openJsonFromUserLocation = async ({
103+
title,
104+
}: {
105+
title: string;
106+
}): Promise<{ content: string; sourcePath: string }> => {
107+
const electronWindow = getElectronWindow();
108+
const dialog = getElectronDialog(electronWindow);
109+
const result = await dialog.showOpenDialog({
110+
title,
111+
properties: ["openFile"],
112+
filters: [{ name: "JSON files", extensions: ["json"] }],
113+
});
114+
if (result.canceled || !result.filePaths[0]) {
115+
throw new NativeFileDialogCancelledError();
116+
}
117+
const fsPromises = getFsPromises(electronWindow);
118+
const sourcePath = result.filePaths[0];
119+
const content = await fsPromises.readFile(sourcePath, "utf8");
120+
return { content, sourcePath };
121+
};

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",

0 commit comments

Comments
 (0)