Skip to content
Closed
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
132 changes: 132 additions & 0 deletions apps/obsidian/src/components/ExportSpecsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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)],
});

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()} />
);
}
}
43 changes: 43 additions & 0 deletions apps/obsidian/src/components/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { usePlugin } from "./PluginContext";
import { setIcon } from "obsidian";
import SuggestInput from "./SuggestInput";
import { DiscourseGraphLogoIcon, SlackLogoIcon } from "./Icons";
import { openExportSpecsModal } from "./ExportSpecsModal";
import { openImportSpecsModal } from "./ImportSpecsModal";
import { getDgSchemaFileName } from "~/utils/specValidation";

const DOCS_URL = "https://discoursegraphs.com/docs/obsidian";
const COMMUNITY_URL =
Expand Down Expand Up @@ -148,6 +151,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 @@ -270,6 +274,26 @@ const GeneralSettings = () => {
</div>
</div>

<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Import discourse graph schema</div>
<div className="setting-item-description">
Choose a schema JSON file from your computer and preview how it maps
to your existing node types, relation types, relation triples, and
templates.
</div>
</div>
<div className="setting-item-control">
<button
type="button"
className="rounded border px-3 py-1.5 text-sm"
onClick={() => openImportSpecsModal(plugin)}
>
Open import modal
</button>
</div>
</div>

<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Node tag hotkey</div>
Expand Down Expand Up @@ -298,6 +322,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
59 changes: 59 additions & 0 deletions apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ImportPreviewStats, LoadedSchemaFile } from "~/utils/specImport";

export const ImportSchemaPreviewSummary = ({
loadedSchemaFile,
previewStats,
}: {
loadedSchemaFile: LoadedSchemaFile;
previewStats: ImportPreviewStats;
}) => {
return (
<>
<div className="mb-4 rounded border p-3 text-sm">
<div className="font-medium">Schema file metadata</div>
<div className="text-muted mt-1">
Vault:{" "}
<span className="font-medium">
{loadedSchemaFile.schemaFile.vaultName}
</span>
</div>
<div className="text-muted">
Exported at:{" "}
<span className="font-medium">
{loadedSchemaFile.schemaFile.exportedAt}
</span>
</div>
<div className="text-muted">
Plugin version:{" "}
<span className="font-medium">
{loadedSchemaFile.schemaFile.pluginVersion}
</span>
</div>
</div>

<div className="mb-4 rounded border p-3 text-sm">
<div className="font-medium">Preview (full schema file)</div>
<div className="text-muted mt-1">
Node types: {previewStats.nodeTypes.total} total (
{previewStats.nodeTypes.new} new, {previewStats.nodeTypes.existing}{" "}
existing)
</div>
<div className="text-muted">
Relation types: {previewStats.relationTypes.total} total (
{previewStats.relationTypes.new} new,{" "}
{previewStats.relationTypes.existing} existing)
</div>
<div className="text-muted">
Relation triples: {previewStats.discourseRelations.total} total (
{previewStats.discourseRelations.new} new,{" "}
{previewStats.discourseRelations.existing} existing)
</div>
<div className="text-muted">
Templates: {previewStats.templates.total} total (
{previewStats.templates.new} new, {previewStats.templates.existing}{" "}
existing)
</div>
</div>
</>
);
};
Loading