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
134 changes: 134 additions & 0 deletions apps/obsidian/src/components/ExportSpecsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { App, Notice } from "obsidian";
import { useMemo, useState } from "react";
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,
type SchemaSelectionSource,
} from "~/components/useSchemaSelection";
import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody";
import { ReactRootModal } from "~/components/ReactRootModal";

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

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

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

const source = useMemo<SchemaSelectionSource>(() => {
return {
nodeTypes: plugin.settings.nodeTypes,
relationTypes: plugin.settings.relationTypes,
relationTriples: plugin.settings.discourseRelations,
templateNames: getTemplateFiles(plugin.app),
};
}, [
plugin.app,
plugin.settings.discourseRelations,
plugin.settings.nodeTypes,
plugin.settings.relationTypes,
]);

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.relationIds.length > 0 ||
payload.templateNames.length > 0;
if (!hasSelection) {
new Notice("Select at least one schema item or template to export.");
return;
}

setIsExporting(true);
try {
const result = await exportSchemaSelection({
plugin,
selection: {
nodeTypeIds: payload.nodeTypeIds,
relationTypeIds: payload.relationTypeIds,
discourseRelationIds: payload.relationIds,
templateNames: payload.templateNames,
},
});

const warningSuffix =
result.warnings.length > 0
? ` (${result.warnings.length} warning${result.warnings.length === 1 ? "" : "s"})`
: "";

new Notice(
`Exported schema to ${result.filePath}${warningSuffix}.`,
6000,
);

if (result.warnings.length > 0) {
for (const warning of result.warnings) {
new Notice(warning, 6000);
}
}

onClose();
} catch (error) {
if (error instanceof NativeFileDialogCancelledError) {
return;
}
console.error("Failed to export schema:", error);
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}
emptyTemplateText="No templates found in your Templates folder."
onDependencyViolation={(message) => new Notice(message)}
footerSecondaryLabel="Cancel"
onFooterSecondaryClick={onClose}
footerPrimaryLabel={isExporting ? "Exporting..." : "Export schema"}
onFooterPrimaryClick={() => void handleExport()}
isFooterPrimaryDisabled={isExporting}
/>
);
};

export class ExportSpecsModal extends ReactRootModal {
private plugin: DiscourseGraphPlugin;

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

protected renderContent() {
return (
<ExportSpecsContent plugin={this.plugin} onClose={() => this.close()} />
);
}
}
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={() => void openExportSpecsModal(plugin)}
>
Open export modal
</button>
</div>
</div>

<InfoSection />
</div>
);
Expand Down
9 changes: 9 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,14 @@ 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
131 changes: 131 additions & 0 deletions apps/obsidian/src/utils/specExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { TFile } from "obsidian";
import type DiscourseGraphPlugin from "~/index";
import type {
DiscourseNode,
DiscourseRelation,
DiscourseSchemaFile,
DiscourseSchemaTemplate,
} from "~/types";
import {
DG_SCHEMA_EXPORT_VERSION,
getDgSchemaFileName,
} from "~/utils/specValidation";
import { getTemplatePluginInfo } from "~/utils/templates";
import { saveJsonToUserLocation } from "~/utils/nativeJsonFileDialogs";

export type SpecExportSelection = {
nodeTypeIds: string[];
relationTypeIds: string[];
discourseRelationIds: string[];
templateNames: string[];
};

export type SpecExportResult = {
filePath: string;
warnings: string[];
};

const asMap = <T extends { id: string }>(items: T[]): Map<string, T> => {
return new Map(items.map((item) => [item.id, item]));
};

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

if (!isEnabled || !folderPath) {
if (templateNames.length > 0) {
warnings.push(
"Templates plugin is not enabled or folder is not configured; template content was skipped.",
);
}
return { templates, warnings };
}

for (const templateName of templateNames) {
const templatePath = `${folderPath}/${templateName}.md`;
const templateFile = plugin.app.vault.getAbstractFileByPath(templatePath);

if (!(templateFile instanceof TFile)) {
warnings.push(`Template file not found: ${templateName}.md`);
continue;
}

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

return { templates, warnings };
};

const buildSchemaExportPayload = async ({
plugin,
selection,
}: {
plugin: DiscourseGraphPlugin;
selection: SpecExportSelection;
}): Promise<{ payload: DiscourseSchemaFile; warnings: string[] }> => {
const nodeTypeMap = asMap(plugin.settings.nodeTypes);
const relationTypeMap = asMap(plugin.settings.relationTypes);
const discourseRelationMap = asMap(plugin.settings.discourseRelations);

const selectedNodeTypes: DiscourseNode[] = selection.nodeTypeIds
.map((id) => nodeTypeMap.get(id))
.filter((nodeType): nodeType is DiscourseNode => !!nodeType);

const selectedRelationTypes = selection.relationTypeIds
.map((id) => relationTypeMap.get(id))
.filter((relationType) => !!relationType);

const selectedDiscourseRelations: DiscourseRelation[] =
selection.discourseRelationIds
.map((id) => discourseRelationMap.get(id))
.filter((relation): relation is DiscourseRelation => !!relation);

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

const payload: DiscourseSchemaFile = {
version: DG_SCHEMA_EXPORT_VERSION,
exportedAt: new Date().toISOString(),
pluginVersion: plugin.manifest.version,
vaultName: plugin.app.vault.getName(),
nodeTypes: selectedNodeTypes,
relationTypes: selectedRelationTypes,
discourseRelations: selectedDiscourseRelations,
templates,
};

return { payload, warnings };
};

export const exportSchemaSelection = async ({
plugin,
selection,
}: {
plugin: DiscourseGraphPlugin;
selection: SpecExportSelection;
}): Promise<SpecExportResult> => {
const { payload, warnings } = await buildSchemaExportPayload({
plugin,
selection,
});
const serializedPayload = JSON.stringify(payload, null, 2);
const fileName = getDgSchemaFileName(plugin.app.vault.getName());
const filePath = await saveJsonToUserLocation({
title: "Export discourse graph schema",
fileName,
content: serializedPayload,
});

return { filePath, warnings };
};