diff --git a/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx b/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx new file mode 100644 index 000000000..5ba1f0e5f --- /dev/null +++ b/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx @@ -0,0 +1,59 @@ +import type { ImportPreviewStats, LoadedSchemaFile } from "~/utils/specImport"; + +export const ImportSchemaPreviewSummary = ({ + loadedSchemaFile, + previewStats, +}: { + loadedSchemaFile: LoadedSchemaFile; + previewStats: ImportPreviewStats; +}) => { + return ( + <> +
+
Schema file metadata
+
+ Vault:{" "} + + {loadedSchemaFile.schemaFile.vaultName} + +
+
+ Exported at:{" "} + + {loadedSchemaFile.schemaFile.exportedAt} + +
+
+ Plugin version:{" "} + + {loadedSchemaFile.schemaFile.pluginVersion} + +
+
+ +
+
Preview (full schema file)
+
+ Node types: {previewStats.nodeTypes.total} total ( + {previewStats.nodeTypes.new} new, {previewStats.nodeTypes.existing}{" "} + existing) +
+
+ Relation types: {previewStats.relationTypes.total} total ( + {previewStats.relationTypes.new} new,{" "} + {previewStats.relationTypes.existing} existing) +
+
+ Relation triples: {previewStats.discourseRelations.total} total ( + {previewStats.discourseRelations.new} new,{" "} + {previewStats.discourseRelations.existing} existing) +
+
+ Templates: {previewStats.templates.total} total ( + {previewStats.templates.new} new, {previewStats.templates.existing}{" "} + existing) +
+
+ + ); +}; diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx new file mode 100644 index 000000000..e1d457805 --- /dev/null +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -0,0 +1,350 @@ +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 SpecImportApplyResult, + type SpecImportPreview, +} from "~/utils/specImport"; +import type { SchemaConflict } from "~/utils/schemaFieldDiff"; +import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs"; +import { + useSchemaSelection, + type SchemaSelectionState, +} from "~/components/useSchemaSelection"; +import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; +import { ImportSchemaPreviewSummary } from "~/components/ImportSchemaPreviewSummary"; +import { SchemaFieldChoiceStep } from "~/components/SchemaFieldChoiceStep"; +import { useSchemaMergePlan } from "~/components/useSchemaMergePlan"; + +type ImportSpecsModalProps = { + plugin: DiscourseGraphPlugin; + onClose: () => void; +}; + +/** A step of its own: per-field choices folded into the selection list would bury the decision that changes existing data. */ +type ImportStep = "select" | "choose"; + +export const openImportSpecsModal = (plugin: DiscourseGraphPlugin): void => { + new ImportSpecsModal(plugin).open(); +}; + +/** Overlaps are computed for the whole file, so drop the ones not being imported. */ +const filterConflictsToSelection = ({ + conflicts, + selection, +}: { + conflicts: SchemaConflict[]; + selection: SchemaSelectionState; +}): SchemaConflict[] => { + return conflicts.filter((conflict) => { + if (conflict.category === "nodeType") { + return selection.selectedNodeTypeIds.has(conflict.schemaId); + } + if (conflict.category === "relationType") { + return selection.selectedRelationTypeIds.has(conflict.schemaId); + } + return selection.selectedTemplateNames.has(conflict.schemaId); + }); +}; + +const buildExistingItemNotes = ({ + existingSchemaIds, + conflicts, + category, +}: { + existingSchemaIds: ReadonlySet; + conflicts: SchemaConflict[]; + category: SchemaConflict["category"]; +}): Map => { + const changeCountBySchemaId = new Map( + conflicts + .filter((conflict) => conflict.category === category) + .map((conflict) => [conflict.schemaId, conflict.changes.length]), + ); + return new Map( + [...existingSchemaIds].map((schemaId) => { + const changeCount = changeCountBySchemaId.get(schemaId); + return [ + schemaId, + changeCount + ? `in vault, ${changeCount} field(s) differ` + : "in vault, identical", + ]; + }), + ); +}; + +const buildImportCompleteMessage = ({ + created, + merged, +}: SpecImportApplyResult): string => { + const createdMessage = `Import complete: ${created.nodeTypes} node type(s), ${created.relationTypes} relation type(s), ${created.discourseRelations} relation triple(s), and ${created.templates} template(s) created.`; + const mergedTotal = + merged.nodeTypes + merged.relationTypes + merged.templates; + if (mergedTotal === 0) return createdMessage; + return `${createdMessage} Updated ${merged.nodeTypes} node type(s) and ${merged.relationTypes} relation type(s), and added ${merged.templates} template copy(ies).`; +}; + +const ImportPreviewSelection = ({ + plugin, + loadedSchemaFile, + previewStats, + conflicts, + isApplyingImport, + setIsApplyingImport, + onResetPreview, + onClose, +}: { + plugin: DiscourseGraphPlugin; + loadedSchemaFile: LoadedSchemaFile; + previewStats: ImportPreviewStats; + conflicts: SchemaConflict[]; + isApplyingImport: boolean; + setIsApplyingImport: (value: boolean) => void; + onResetPreview: () => void; + onClose: () => void; +}) => { + const [step, setStep] = useState("select"); + 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 selectedConflicts = filterConflictsToSelection({ + conflicts, + selection, + }); + + const mergePlan = useSchemaMergePlan({ + resetKey: `${loadedSchemaFile.sourcePath}|${selectedConflicts + .map((conflict) => `${conflict.category}:${conflict.schemaId}`) + .join(",")}`, + }); + + const hasAnySelection = + selection.selectedNodeTypeIds.size > 0 || + selection.selectedRelationTypeIds.size > 0 || + selection.selectedRelationIds.size > 0 || + selection.selectedTemplateNames.size > 0; + + const handleApplyImport = async (): Promise => { + setIsApplyingImport(true); + const warnings: string[] = []; + try { + const result = await applySchemaImportSelection({ + plugin, + loadedSchemaFile, + selection: selection.asSelectionPayload(), + mergePlan: mergePlan.asMergePlan(), + onWarning: (message) => warnings.push(message), + }); + + new Notice(buildImportCompleteMessage(result), 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); + } + }; + + const handleAdvanceFromSelection = (): void => { + if (!hasAnySelection) { + new Notice("Select at least one item to import."); + return; + } + if (selectedConflicts.length > 0) { + setStep("choose"); + return; + } + void handleApplyImport(); + }; + + const isChoosingFields = step === "choose"; + const primaryLabel = isApplyingImport + ? "Importing..." + : isChoosingFields || selectedConflicts.length === 0 + ? "Import selected" + : `Choose what to keep (${selectedConflicts.length})`; + + return ( +
+

+ {isChoosingFields ? "Choose what to keep" : "Import schema preview"} +

+

+ Source file: {loadedSchemaFile.sourcePath} +

+ + {isChoosingFields ? ( + + ) : ( + <> + + new Notice(message)} + nodeTypeNotes={buildExistingItemNotes({ + existingSchemaIds: loadedSchemaFile.matchPlan.existingNodeTypeIds, + conflicts, + category: "nodeType", + })} + relationTypeNotes={buildExistingItemNotes({ + existingSchemaIds: + loadedSchemaFile.matchPlan.existingRelationTypeIds, + conflicts, + category: "relationType", + })} + /> + + )} + +
+ + +
+
+ ); +}; + +const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => { + const [preview, setPreview] = useState(null); + const [isSelectingFile, setIsSelectingFile] = useState(false); + const [isApplyingImport, setIsApplyingImport] = useState(false); + + const handleSelectSchemaFile = async (): Promise => { + 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 ( +
+

Import discourse graph schema

+

+ Pick a dg-schema-*.json file from your computer to + preview and choose exactly what to import. +

+ +
+ Same dependency rules as export apply here during selection. +
+ +
+ + +
+
+ ); + } + + return ( + 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( + + this.close()} /> + , + ); + } + + onClose(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } +} diff --git a/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx b/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx new file mode 100644 index 000000000..18b64fc83 --- /dev/null +++ b/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx @@ -0,0 +1,312 @@ +import type { + SchemaConflict, + SchemaFieldChange, +} from "~/utils/schemaFieldDiff"; +import type { SchemaMergePlanState } from "~/components/useSchemaMergePlan"; +import { getImportedTemplateFileName } from "~/utils/templates"; +import { COLOR_PALETTE } from "~/utils/tldrawColors"; + +const FIELD_LABELS: Record = { + format: "Format", + template: "Template", + description: "Description", + shortcut: "Shortcut", + color: "Color", + tag: "Tag", + keyImage: "Key image", + folderPath: "Folder path", + complement: "Complement", + content: "File contents", +}; + +const CATEGORY_HEADINGS: Record = { + nodeType: "Node types", + relationType: "Relation types", + template: "Templates", +}; + +const CATEGORY_ORDER: SchemaConflict["category"][] = [ + "nodeType", + "relationType", + "template", +]; + +const formatFieldValue = (value: SchemaFieldChange["localValue"]): string => { + if (value === undefined || value === "") return "empty"; + if (typeof value === "boolean") return value ? "on" : "off"; + // Collapse newlines so a multi-line value cannot stretch the row; the cell wraps rather than truncating. + const collapsed = value.replace(/\s+/g, " ").trim(); + return collapsed.length === 0 ? "empty" : collapsed; +}; + +const isEmptyValue = (value: SchemaFieldChange["localValue"]): boolean => { + return ( + value === undefined || (typeof value === "string" && value.trim() === "") + ); +}; + +/** Node types store a hex color, relation types a tldraw color name; both resolve to a swatch. */ +const resolveSwatchColor = ({ + field, + value, +}: { + field: string; + value: SchemaFieldChange["localValue"]; +}): string | undefined => { + if (field !== "color" || typeof value !== "string") return undefined; + return COLOR_PALETTE[value] ?? (value.startsWith("#") ? value : undefined); +}; + +const describeTemplateBody = ( + value: SchemaFieldChange["localValue"], +): string => { + if (typeof value !== "string") return formatFieldValue(value); + const lineCount = value.split("\n").length; + return `${value.length} bytes, ${lineCount} line${lineCount === 1 ? "" : "s"}`; +}; + +const ChoiceCell = ({ + conflict, + change, + isImportedSide, + mergePlan, +}: { + conflict: SchemaConflict; + change: SchemaFieldChange; + isImportedSide: boolean; + mergePlan: SchemaMergePlanState; +}) => { + const takesImported = mergePlan.isFieldSelected({ + category: conflict.category, + schemaId: conflict.schemaId, + field: change.field, + }); + const isChosen = isImportedSide ? takesImported : !takesImported; + const value = isImportedSide ? change.importedValue : change.localValue; + const swatch = resolveSwatchColor({ field: change.field, value }); + + const isTemplateBody = + conflict.category === "template" && change.field === "content"; + const text = isTemplateBody + ? describeTemplateBody(value) + : formatFieldValue(value); + + return ( + + + + ); +}; + +const ItemChoiceTable = ({ + conflict, + mergePlan, + sourceVaultName, +}: { + conflict: SchemaConflict; + mergePlan: SchemaMergePlanState; + sourceVaultName: string; +}) => { + const selectedCount = mergePlan.countSelectedFields(conflict); + const isAllLocal = selectedCount === 0; + const isAllImported = selectedCount === conflict.changes.length; + + return ( +
+
+ {conflict.label} +
+ + + + + + + + + + + + + + + {conflict.changes.map((change) => ( + + + + + + ))} + +
+ Field + + + + +
+ {FIELD_LABELS[change.field] ?? change.field} +
+
+ ); +}; + +export const SchemaFieldChoiceStep = ({ + conflicts, + mergePlan, + sourceVaultName, +}: { + conflicts: SchemaConflict[]; + mergePlan: SchemaMergePlanState; + sourceVaultName: string; +}) => { + const totalFields = conflicts.reduce( + (total, conflict) => total + conflict.changes.length, + 0, + ); + const selectedFields = conflicts.reduce( + (total, conflict) => total + mergePlan.countSelectedFields(conflict), + 0, + ); + const templateConflicts = conflicts.filter( + (conflict) => conflict.category === "template", + ); + + return ( + <> +
+
+ {conflicts.length} item(s) already in this vault +
+

+ Every field starts on your value. Names are never changed by an + import, because renaming a type does not retag the pages already using + it. +

+
+ +
+ {CATEGORY_ORDER.map((category) => { + const categoryConflicts = conflicts.filter( + (conflict) => conflict.category === category, + ); + if (categoryConflicts.length === 0) return null; + + return ( +
+

+ {CATEGORY_HEADINGS[category]} ({categoryConflicts.length}) +

+ {categoryConflicts.map((conflict) => ( + + ))} +
+ ); + })} +
+ + {templateConflicts.length > 0 && ( +

+ ⧉ Your template file is never overwritten — the imported version is + added beside it as{" "} + {templateConflicts + .map((conflict) => + getImportedTemplateFileName({ + templateName: conflict.schemaId, + sourceName: sourceVaultName, + }), + ) + .map((name) => `${name}.md`) + .join(", ")} + , and node types using it are repointed at the copy. +

+ )} + +

+ {selectedFields} of {totalFields} field(s) will come from the file +

+ + ); +}; diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx index 23848e1da..79dcc4e7e 100644 --- a/apps/obsidian/src/components/SchemaSelectionPanel.tsx +++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx @@ -7,12 +7,17 @@ type SchemaSelectionPanelProps = { source: SchemaSelectionSource; selection: SchemaSelectionState; onDependencyViolation?: (message: string) => void; + /** Import-only: marks what the vault already has. Export has nothing to compare against and passes neither. */ + nodeTypeNotes?: ReadonlyMap; + relationTypeNotes?: ReadonlyMap; }; export const SchemaSelectionPanel = ({ source, selection, onDependencyViolation, + nodeTypeNotes, + relationTypeNotes, }: SchemaSelectionPanelProps) => { const { selectedNodeTypeIds, @@ -113,6 +118,11 @@ export const SchemaSelectionPanel = ({ disabled={isRequired} /> {nodeType.name} + {nodeTypeNotes?.get(nodeType.id) && ( + + {nodeTypeNotes.get(nodeType.id)} + + )} {isRequired && ( required by selected triple @@ -171,6 +181,11 @@ export const SchemaSelectionPanel = ({ disabled={isRequired} /> {relationType.label} + {relationTypeNotes?.get(relationType.id) && ( + + {relationTypeNotes.get(relationType.id)} + + )} {isRequired && ( required by selected triple diff --git a/apps/obsidian/src/components/useSchemaMergePlan.ts b/apps/obsidian/src/components/useSchemaMergePlan.ts new file mode 100644 index 000000000..a085fdf2f --- /dev/null +++ b/apps/obsidian/src/components/useSchemaMergePlan.ts @@ -0,0 +1,186 @@ +import { useEffect, useState } from "react"; +import type { + SchemaConflict, + SchemaConflictCategory, +} from "~/utils/schemaFieldDiff"; +import type { SchemaMergePlan } from "~/utils/specImport"; + +/** Kept out of useSchemaSelection: a choice only outlives the selection it belongs to, so it resets separately. */ +export type SchemaMergePlanState = { + isFieldSelected: (args: { + category: SchemaConflictCategory; + schemaId: string; + field: string; + }) => boolean; + toggleField: (args: { + category: SchemaConflictCategory; + schemaId: string; + field: string; + shouldSelect: boolean; + }) => void; + setAllFields: (args: { + conflict: SchemaConflict; + shouldSelect: boolean; + }) => void; + countSelectedFields: (conflict: SchemaConflict) => number; + asMergePlan: () => SchemaMergePlan; +}; + +type FieldSelections = ReadonlyMap>; + +/** An empty entry and an absent one mean the same to the apply path, so empties are dropped. */ +const withFields = ({ + selections, + schemaId, + fields, +}: { + selections: FieldSelections; + schemaId: string; + fields: ReadonlySet; +}): FieldSelections => { + const nextSelections = new Map(selections); + if (fields.size === 0) { + nextSelections.delete(schemaId); + } else { + nextSelections.set(schemaId, fields); + } + return nextSelections; +}; + +const withFieldToggled = ({ + selections, + schemaId, + field, + shouldSelect, +}: { + selections: FieldSelections; + schemaId: string; + field: string; + shouldSelect: boolean; +}): FieldSelections => { + const nextFields = new Set(selections.get(schemaId) ?? []); + if (shouldSelect) { + nextFields.add(field); + } else { + nextFields.delete(field); + } + return withFields({ selections, schemaId, fields: nextFields }); +}; + +const withNameToggled = ({ + names, + name, + shouldSelect, +}: { + names: ReadonlySet; + name: string; + shouldSelect: boolean; +}): ReadonlySet => { + const nextNames = new Set(names); + if (shouldSelect) { + nextNames.add(name); + } else { + nextNames.delete(name); + } + return nextNames; +}; + +export const useSchemaMergePlan = ({ + resetKey, +}: { + resetKey: string; +}): SchemaMergePlanState => { + const [nodeTypeFields, setNodeTypeFields] = useState( + () => new Map(), + ); + const [relationTypeFields, setRelationTypeFields] = useState( + () => new Map(), + ); + const [templateNames, setTemplateNames] = useState>( + () => new Set(), + ); + + useEffect(() => { + setNodeTypeFields(new Map()); + setRelationTypeFields(new Map()); + setTemplateNames(new Set()); + }, [resetKey]); + + const isFieldSelected: SchemaMergePlanState["isFieldSelected"] = ({ + category, + schemaId, + field, + }) => { + if (category === "template") return templateNames.has(schemaId); + const selections = + category === "nodeType" ? nodeTypeFields : relationTypeFields; + return selections.get(schemaId)?.has(field) ?? false; + }; + + const toggleField: SchemaMergePlanState["toggleField"] = ({ + category, + schemaId, + field, + shouldSelect, + }) => { + if (category === "template") { + setTemplateNames((previousNames) => + withNameToggled({ names: previousNames, name: schemaId, shouldSelect }), + ); + return; + } + const setSelections = + category === "nodeType" ? setNodeTypeFields : setRelationTypeFields; + setSelections((previousSelections) => + withFieldToggled({ + selections: previousSelections, + schemaId, + field, + shouldSelect, + }), + ); + }; + + return { + isFieldSelected, + toggleField, + setAllFields: ({ conflict, shouldSelect }) => { + if (conflict.category === "template") { + setTemplateNames((previousNames) => + withNameToggled({ + names: previousNames, + name: conflict.schemaId, + shouldSelect, + }), + ); + return; + } + const setSelections = + conflict.category === "nodeType" + ? setNodeTypeFields + : setRelationTypeFields; + setSelections((previousSelections) => + withFields({ + selections: previousSelections, + schemaId: conflict.schemaId, + fields: shouldSelect + ? new Set(conflict.changes.map((change) => change.field)) + : new Set(), + }), + ); + }, + countSelectedFields: (conflict) => + conflict.changes.filter((change) => + isFieldSelected({ + category: conflict.category, + schemaId: conflict.schemaId, + field: change.field, + }), + ).length, + asMergePlan: () => ({ + nodeTypeFields, + relationTypeFields, + templateNames, + }), + }; +}; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 29e7285d1..4d39c9ff4 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -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; @@ -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",