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
27 changes: 27 additions & 0 deletions apps/obsidian/src/components/ReactRootModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { App, Modal } from "obsidian";
import { StrictMode, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";

export abstract class ReactRootModal extends Modal {
private root: Root | null = null;

constructor(app: App) {
super(app);
}

protected abstract renderContent(): ReactNode;

onOpen(): void {
const { contentEl } = this;
contentEl.empty();
this.root = createRoot(contentEl);
this.root.render(<StrictMode>{this.renderContent()}</StrictMode>);
}

onClose(): void {
if (this.root) {
this.root.unmount();
this.root = null;
}
}
}
77 changes: 77 additions & 0 deletions apps/obsidian/src/components/SchemaSelectionModalBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel";
import type { ReactNode } from "react";
import type {
SchemaSelectionSource,
SchemaSelectionState,
} from "~/components/useSchemaSelection";

type SchemaSelectionModalBodyProps = {
title: string;
description: string;
source: SchemaSelectionSource;
selection: SchemaSelectionState;
emptyTemplateText: string;
onDependencyViolation?: (message: string) => void;
beforePanel?: ReactNode;
afterPanel?: ReactNode;
footerSecondaryLabel: string;
onFooterSecondaryClick: () => void;
footerPrimaryLabel: string;
onFooterPrimaryClick: () => void;
isFooterPrimaryDisabled?: boolean;
isFooterSecondaryDisabled?: boolean;
};

export const SchemaSelectionModalBody = ({
title,
description,
source,
selection,
emptyTemplateText,
onDependencyViolation,
beforePanel,
afterPanel,
footerSecondaryLabel,
onFooterSecondaryClick,
footerPrimaryLabel,
onFooterPrimaryClick,
isFooterPrimaryDisabled = false,
isFooterSecondaryDisabled = false,
}: SchemaSelectionModalBodyProps) => {
return (
<div>
<h3 className="mb-2">{title}</h3>
<p className="text-muted mb-4 text-sm">{description}</p>

{beforePanel}

<SchemaSelectionPanel
source={source}
selection={selection}
emptyTemplateText={emptyTemplateText}
onDependencyViolation={onDependencyViolation}
/>

{afterPanel}

<div className="mt-6 flex justify-between">
<button
type="button"
className="px-4 py-2"
onClick={onFooterSecondaryClick}
disabled={isFooterSecondaryDisabled}
>
{footerSecondaryLabel}
</button>
<button
type="button"
className="!bg-accent !text-on-accent rounded px-4 py-2"
onClick={onFooterPrimaryClick}
disabled={isFooterPrimaryDisabled}
>
{footerPrimaryLabel}
</button>
</div>
</div>
);
};
Loading