Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
122 changes: 122 additions & 0 deletions apps/obsidian/src/components/ExportSpecsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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}
onDependencyViolation={(message) => new Notice(message)}
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;
}
}
}
22 changes: 22 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";

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

const handleToggleChange = (newValue: boolean) => {
setShowIdsInFrontmatter(newValue);
Expand Down Expand Up @@ -298,6 +301,25 @@ 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>

<InfoSection />
</div>
);
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 @@ -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";
Expand Down Expand Up @@ -194,6 +195,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,
});
};