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
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>
</>
);
};
215 changes: 215 additions & 0 deletions apps/obsidian/src/components/ImportSpecsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { Modal, Notice } from "obsidian";
import { StrictMode, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { ZodError } from "zod";
import type DiscourseGraphPlugin from "~/index";
import {
applySchemaImportSelection,
pickAndPreviewSchemaImport,
type ImportPreviewStats,
type LoadedSchemaFile,
type SpecImportPreview,
} from "~/utils/specImport";
import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs";
import { useSchemaSelection } from "~/components/useSchemaSelection";
import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody";
import { ImportSchemaPreviewSummary } from "~/components/ImportSchemaPreviewSummary";

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

export const openImportSpecsModal = (plugin: DiscourseGraphPlugin): void => {
new ImportSpecsModal(plugin).open();
};

const ImportPreviewSelection = ({
plugin,
loadedSchemaFile,
previewStats,
isApplyingImport,
setIsApplyingImport,
onResetPreview,
onClose,
}: {
plugin: DiscourseGraphPlugin;
loadedSchemaFile: LoadedSchemaFile;
previewStats: ImportPreviewStats;
isApplyingImport: boolean;
setIsApplyingImport: (value: boolean) => void;
onResetPreview: () => void;
onClose: () => void;
}) => {
const schemaFile = loadedSchemaFile.schemaFile;
const source = {
nodeTypes: schemaFile.nodeTypes,
relationTypes: schemaFile.relationTypes,
relationTriples: schemaFile.discourseRelations,
templateNames: schemaFile.templates.map((template) => template.name),
};

const selection = useSchemaSelection({
source,
resetKey: loadedSchemaFile.sourcePath,
});

const handleApplyImport = async (): Promise<void> => {
const selected = selection.asSelectionPayload();
const hasAnySelection =
selected.nodeTypeIds.length > 0 ||
selected.relationTypeIds.length > 0 ||
selected.discourseRelationIds.length > 0 ||
selected.templateNames.length > 0;
if (!hasAnySelection) {
new Notice("Select at least one item to import.");
return;
}

setIsApplyingImport(true);
const warnings: string[] = [];
try {
const { created } = await applySchemaImportSelection({
plugin,
loadedSchemaFile,
selection: selected,
onWarning: (message) => warnings.push(message),
});

new Notice(
`Import complete: ${created.nodeTypes} node type(s), ${created.relationTypes} relation type(s), ${created.discourseRelations} relation triple(s), and ${created.templates} template(s) created.`,
7000,
);
if (warnings.length > 0) {
new Notice(`Import warnings:\n${warnings.join("\n")}`, 6000);
}
onClose();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
new Notice(`Failed to import schema: ${message}`, 6000);
// Only the failure path stays mounted; the success path unmounted at onClose()
setIsApplyingImport(false);
}
Comment thread
trangdoan982 marked this conversation as resolved.
};

return (
<>
<ImportSchemaPreviewSummary
loadedSchemaFile={loadedSchemaFile}
previewStats={previewStats}
/>
<SchemaSelectionModalBody
title="Import schema preview"
description={`Source file: ${loadedSchemaFile.sourcePath}`}
source={source}
selection={selection}
onDependencyViolation={(message) => new Notice(message)}
footerSecondaryLabel="Choose another file"
onFooterSecondaryClick={onResetPreview}
footerPrimaryLabel={
isApplyingImport ? "Importing..." : "Import selected"
}
onFooterPrimaryClick={() => void handleApplyImport()}
isFooterSecondaryDisabled={isApplyingImport}
isFooterPrimaryDisabled={isApplyingImport}
/>
</>
);
};

const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => {
const [preview, setPreview] = useState<SpecImportPreview | null>(null);
const [isSelectingFile, setIsSelectingFile] = useState(false);
const [isApplyingImport, setIsApplyingImport] = useState(false);

const handleSelectSchemaFile = async (): Promise<void> => {
setIsSelectingFile(true);
try {
const nextPreview = await pickAndPreviewSchemaImport({ plugin });
setPreview(nextPreview);
} catch (error) {
if (error instanceof NativeFileDialogCancelledError) return;
if (error instanceof ZodError) {
const fields = error.issues.map((i) => i.path.join(".")).join(", ");
new Notice(
`Schema file is incompatible with this version of the plugin. Invalid or missing fields: ${fields}`,
8000,
);
return;
}
const message = error instanceof Error ? error.message : String(error);
new Notice(`Failed to load schema file: ${message}`, 6000);
} finally {
setIsSelectingFile(false);
}
};

if (!preview) {
return (
<div>
<h3 className="mb-2">Import discourse graph schema</h3>
<p className="text-muted mb-4 text-sm">
Pick a <code>dg-schema-*.json</code> file from your computer to
preview and choose exactly what to import.
</p>

<div className="mb-4 rounded border p-3 text-sm">
Same dependency rules as export apply here during selection.
</div>

<div className="flex justify-between">
<button type="button" className="px-4 py-2" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="!bg-accent !text-on-accent rounded px-4 py-2"
onClick={() => void handleSelectSchemaFile()}
disabled={isSelectingFile}
>
{isSelectingFile ? "Opening..." : "Choose schema file"}
</button>
</div>
</div>
);
}

return (
<ImportPreviewSelection
plugin={plugin}
loadedSchemaFile={preview.loadedSchemaFile}
previewStats={preview.previewStats}
isApplyingImport={isApplyingImport}
setIsApplyingImport={setIsApplyingImport}
onResetPreview={() => setPreview(null)}
onClose={onClose}
/>
);
};

export class ImportSpecsModal 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>
<ImportSpecsContent plugin={this.plugin} onClose={() => this.close()} />
</StrictMode>,
);
}

onClose(): void {
if (this.root) {
this.root.unmount();
this.root = null;
}
}
}
9 changes: 9 additions & 0 deletions apps/obsidian/src/utils/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { addRelationIfRequested } from "~/components/canvas/utils/relationJsonUt
import type { DiscourseNode } from "~/types";
import { TldrawView } from "~/components/canvas/TldrawView";
import { createBaseForNodeType } from "./baseForNodeType";
import { openImportSpecsModal } from "~/components/ImportSpecsModal";

type ModifyNodeSubmitParams = {
nodeType: DiscourseNode;
Expand Down Expand Up @@ -201,6 +202,14 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => {
callback: () => openExportSpecsModal(plugin),
});

plugin.addCommand({
id: "import-dg-schema",
name: "Import discourse graph schema",
callback: () => {
openImportSpecsModal(plugin);
},
});

plugin.addCommand({
id: "toggle-discourse-context",
name: "Toggle discourse context",
Expand Down