diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx new file mode 100644 index 000000000..e418237bf --- /dev/null +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -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(() => { + 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 => { + 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 ( + 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 ( + this.close()} /> + ); + } +} diff --git a/apps/obsidian/src/components/GeneralSettings.tsx b/apps/obsidian/src/components/GeneralSettings.tsx index 9666a1217..62e062c73 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -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 = @@ -148,6 +151,7 @@ const GeneralSettings = () => { const [nodeTagHotkey, setNodeTagHotkey] = useState( plugin.settings.nodeTagHotkey, ); + const schemaFileName = getDgSchemaFileName(plugin.app.vault.getName()); const handleToggleChange = (newValue: boolean) => { setShowIdsInFrontmatter(newValue); @@ -270,6 +274,26 @@ const GeneralSettings = () => { +
+
+
Import discourse graph schema
+
+ Choose a schema JSON file from your computer and preview how it maps + to your existing node types, relation types, relation triples, and + templates. +
+
+
+ +
+
+
Node tag hotkey
@@ -298,6 +322,25 @@ const GeneralSettings = () => {
+
+
+
Export discourse graph schema
+
+ Export selected node types, relation types, relation triples, and + templates to a JSON file named {schemaFileName}. +
+
+
+ +
+
+ ); 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..7558080e8 --- /dev/null +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -0,0 +1,213 @@ +import { App, Notice } from "obsidian"; +import { useMemo, useState } from "react"; +import type DiscourseGraphPlugin from "~/index"; +import { + applySchemaImportSelection, + pickAndPreviewSchemaImport, + type ImportPreviewStats, + type LoadedSchemaFile, + type SpecImportPreview, +} from "~/utils/specImport"; +import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs"; +import { + useSchemaSelection, + type SchemaSelectionSource, +} from "~/components/useSchemaSelection"; +import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody"; +import { ImportSchemaPreviewSummary } from "~/components/ImportSchemaPreviewSummary"; +import { ReactRootModal } from "~/components/ReactRootModal"; + +type ImportSpecsModalProps = { + plugin: DiscourseGraphPlugin; + onClose: () => void; +}; + +export const openImportSpecsModal = (plugin: DiscourseGraphPlugin): void => { + new ImportSpecsModal(plugin.app, 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 source = useMemo(() => { + const schemaFile = loadedSchemaFile.schemaFile; + return { + nodeTypes: schemaFile.nodeTypes, + relationTypes: schemaFile.relationTypes, + relationTriples: schemaFile.discourseRelations, + templateNames: schemaFile.templates.map((template) => template.name), + }; + }, [loadedSchemaFile]); + + const selection = useSchemaSelection({ + source, + resetKey: loadedSchemaFile.sourcePath, + }); + + const handleApplyImport = async (): Promise => { + const selected = selection.asSelectionPayload(); + const hasAnySelection = + selected.nodeTypeIds.length > 0 || + selected.relationTypeIds.length > 0 || + selected.relationIds.length > 0 || + selected.templateNames.length > 0; + if (!hasAnySelection) { + new Notice("Select at least one item to import."); + return; + } + + setIsApplyingImport(true); + try { + const result = await applySchemaImportSelection({ + plugin, + loadedSchemaFile, + selection: { + nodeTypeIds: selected.nodeTypeIds, + relationTypeIds: selected.relationTypeIds, + discourseRelationIds: selected.relationIds, + templateNames: selected.templateNames, + }, + }); + + const { created } = result; + 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 (result.warnings.length > 0) { + new Notice( + `Import completed with ${result.warnings.length} warning(s).`, + 6000, + ); + for (const warning of result.warnings) { + new Notice(warning, 6000); + } + } + onClose(); + } catch (error) { + console.error("Failed to apply schema import:", error); + const message = error instanceof Error ? error.message : String(error); + new Notice(`Failed to import schema: ${message}`, 6000); + } finally { + setIsApplyingImport(false); + } + }; + + return ( + new Notice(message)} + beforePanel={ + + } + 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(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; + } + console.error("Failed to load schema import file:", error); + 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 ReactRootModal { + private plugin: DiscourseGraphPlugin; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app); + this.plugin = plugin; + } + + protected renderContent() { + return ( + this.close()} /> + ); + } +} diff --git a/apps/obsidian/src/components/ReactRootModal.tsx b/apps/obsidian/src/components/ReactRootModal.tsx new file mode 100644 index 000000000..566df4d77 --- /dev/null +++ b/apps/obsidian/src/components/ReactRootModal.tsx @@ -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({this.renderContent()}); + } + + onClose(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } +} diff --git a/apps/obsidian/src/components/SchemaSelectionModalBody.tsx b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx new file mode 100644 index 000000000..6a37e440a --- /dev/null +++ b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx @@ -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 ( +
+

{title}

+

{description}

+ + {beforePanel} + + + + {afterPanel} + +
+ + +
+
+ ); +}; diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx new file mode 100644 index 000000000..fd113dce2 --- /dev/null +++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx @@ -0,0 +1,307 @@ +import type { + SchemaSelectionSource, + SchemaSelectionState, +} from "~/components/useSchemaSelection"; + +type SchemaSelectionPanelProps = { + source: SchemaSelectionSource; + selection: SchemaSelectionState; + emptyTemplateText: string; + onDependencyViolation?: (message: string) => void; +}; + +export const SchemaSelectionPanel = ({ + source, + selection, + emptyTemplateText, + onDependencyViolation, +}: SchemaSelectionPanelProps) => { + const { + selectedNodeTypeIds, + selectedRelationTypeIds, + selectedRelationIds, + selectedTemplateNames, + requiredNodeTypeIds, + requiredRelationTypeIds, + selectAllNodeTypes, + deselectOptionalNodeTypes, + toggleNodeType, + selectAllRelationTypes, + deselectOptionalRelationTypes, + toggleRelationType, + selectAllRelationTriples, + deselectAllRelationTriples, + toggleRelationTriple, + selectAllTemplates, + deselectAllTemplates, + toggleTemplate, + } = selection; + + const nodeTypeById = new Map( + source.nodeTypes.map((nodeType) => [nodeType.id, nodeType]), + ); + const relationTypeById = new Map( + source.relationTypes.map((relationType) => [relationType.id, relationType]), + ); + const templateToNodeTypeNames = new Map(); + for (const nodeType of source.nodeTypes) { + if (!nodeType.template) continue; + const current = templateToNodeTypeNames.get(nodeType.template) ?? []; + current.push(nodeType.name); + templateToNodeTypeNames.set(nodeType.template, current); + } + for (const [ + templateName, + nodeTypeNames, + ] of templateToNodeTypeNames.entries()) { + templateToNodeTypeNames.set( + templateName, + [...new Set(nodeTypeNames)].sort((left, right) => + left.localeCompare(right), + ), + ); + } + const referencedTemplateNames = new Set(templateToNodeTypeNames.keys()); + + return ( + <> +
+
Selection summary
+
+ {selectedNodeTypeIds.size} node type(s) + {selectedRelationTypeIds.size} relation type(s) + {selectedRelationIds.size} relation triple(s) + {selectedTemplateNames.size} template(s) +
+
+ +
+
+
+

Node types

+
+ + +
+
+
+ {source.nodeTypes.map((nodeType) => { + const isRequired = requiredNodeTypeIds.has(nodeType.id); + return ( + + ); + })} +
+
+ +
+
+

Relation types

+
+ + +
+
+
+ {source.relationTypes.map((relationType) => { + const isRequired = requiredRelationTypeIds.has(relationType.id); + return ( + + ); + })} +
+
+ +
+
+

Relation triples

+
+ + +
+
+
+ {source.relationTriples.map((relation) => { + const sourceName = + nodeTypeById.get(relation.sourceId)?.name ?? relation.sourceId; + const destinationName = + nodeTypeById.get(relation.destinationId)?.name ?? + relation.destinationId; + const relationTypeLabel = + relationTypeById.get(relation.relationshipTypeId)?.label ?? + relation.relationshipTypeId; + + return ( + + ); + })} +
+
+ +
+
+

Templates

+
+ + +
+
+ {source.templateNames.length === 0 ? ( +

{emptyTemplateText}

+ ) : ( +
+ {source.templateNames.map((templateName) => ( + + ))} +
+ )} +
+
+ + ); +}; diff --git a/apps/obsidian/src/components/useSchemaSelection.ts b/apps/obsidian/src/components/useSchemaSelection.ts new file mode 100644 index 000000000..319f6047f --- /dev/null +++ b/apps/obsidian/src/components/useSchemaSelection.ts @@ -0,0 +1,242 @@ +import { useEffect, useMemo, useState } from "react"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, +} from "~/types"; + +export type SchemaSelectionSource = { + nodeTypes: Pick[]; + relationTypes: Pick[]; + relationTriples: Pick< + DiscourseRelation, + "id" | "sourceId" | "destinationId" | "relationshipTypeId" + >[]; + templateNames: string[]; +}; + +type SelectionToggleResult = { + ok: boolean; + reason?: string; +}; + +export type SchemaSelectionState = { + selectedNodeTypeIds: Set; + selectedRelationTypeIds: Set; + selectedRelationIds: Set; + selectedTemplateNames: Set; + requiredNodeTypeIds: Set; + requiredRelationTypeIds: Set; + selectAllNodeTypes: () => void; + deselectOptionalNodeTypes: () => void; + toggleNodeType: ( + nodeTypeId: string, + shouldSelect: boolean, + ) => SelectionToggleResult; + selectAllRelationTypes: () => void; + deselectOptionalRelationTypes: () => void; + toggleRelationType: ( + relationTypeId: string, + shouldSelect: boolean, + ) => SelectionToggleResult; + selectAllRelationTriples: () => void; + deselectAllRelationTriples: () => void; + toggleRelationTriple: (relationId: string, shouldSelect: boolean) => void; + selectAllTemplates: () => void; + deselectAllTemplates: () => void; + toggleTemplate: (templateName: string, shouldSelect: boolean) => void; + asSelectionPayload: () => { + nodeTypeIds: string[]; + relationTypeIds: string[]; + relationIds: string[]; + templateNames: string[]; + }; +}; + +const updateSet = ( + previousSet: Set, + id: string, + shouldSelect: boolean, +): Set => { + const nextSet = new Set(previousSet); + if (shouldSelect) { + nextSet.add(id); + } else { + nextSet.delete(id); + } + return nextSet; +}; + +export const getReferencedTemplateNames = ( + nodeTypes: SchemaSelectionSource["nodeTypes"], +): Set => { + return new Set( + nodeTypes + .map((nodeType) => nodeType.template) + .filter((template): template is string => !!template), + ); +}; + +export const useSchemaSelection = ({ + source, + initialTemplateNames, + resetKey, +}: { + source: SchemaSelectionSource; + /** + * Template names to pre-select on mount and on reset. Defaults to all + * templates in source when not provided. + */ + initialTemplateNames?: string[]; + resetKey: string; +}): SchemaSelectionState => { + const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState>( + () => new Set(source.nodeTypes.map((nodeType) => nodeType.id)), + ); + const [selectedRelationTypeIds, setSelectedRelationTypeIds] = useState< + Set + >(() => new Set(source.relationTypes.map((relationType) => relationType.id))); + const [selectedRelationIds, setSelectedRelationIds] = useState>( + () => new Set(source.relationTriples.map((relation) => relation.id)), + ); + const [selectedTemplateNames, setSelectedTemplateNames] = useState< + Set + >(() => new Set(initialTemplateNames ?? source.templateNames)); + + // resetKey is the only trigger; source and initialTemplateNames are read + // from the current render's closure when resetKey changes. + useEffect(() => { + setSelectedNodeTypeIds( + new Set(source.nodeTypes.map((nodeType) => nodeType.id)), + ); + setSelectedRelationTypeIds( + new Set(source.relationTypes.map((relationType) => relationType.id)), + ); + setSelectedRelationIds( + new Set(source.relationTriples.map((relation) => relation.id)), + ); + setSelectedTemplateNames( + new Set(initialTemplateNames ?? source.templateNames), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetKey]); + + const requiredRelationTypeIds = useMemo(() => { + const requiredIds = new Set(); + for (const relation of source.relationTriples) { + if (selectedRelationIds.has(relation.id)) { + requiredIds.add(relation.relationshipTypeId); + } + } + return requiredIds; + }, [source.relationTriples, selectedRelationIds]); + + const requiredNodeTypeIds = useMemo(() => { + const requiredIds = new Set(); + for (const relation of source.relationTriples) { + if (!selectedRelationIds.has(relation.id)) { + continue; + } + requiredIds.add(relation.sourceId); + requiredIds.add(relation.destinationId); + } + return requiredIds; + }, [source.relationTriples, selectedRelationIds]); + + useEffect(() => { + setSelectedRelationTypeIds((previousSet) => { + const nextSet = new Set(previousSet); + let didChange = false; + for (const relationTypeId of requiredRelationTypeIds) { + if (!nextSet.has(relationTypeId)) { + nextSet.add(relationTypeId); + didChange = true; + } + } + return didChange ? nextSet : previousSet; + }); + }, [requiredRelationTypeIds]); + + useEffect(() => { + setSelectedNodeTypeIds((previousSet) => { + const nextSet = new Set(previousSet); + let didChange = false; + for (const nodeTypeId of requiredNodeTypeIds) { + if (!nextSet.has(nodeTypeId)) { + nextSet.add(nodeTypeId); + didChange = true; + } + } + return didChange ? nextSet : previousSet; + }); + }, [requiredNodeTypeIds]); + + return { + selectedNodeTypeIds, + selectedRelationTypeIds, + selectedRelationIds, + selectedTemplateNames, + requiredNodeTypeIds, + requiredRelationTypeIds, + selectAllNodeTypes: () => + setSelectedNodeTypeIds( + new Set(source.nodeTypes.map((nodeType) => nodeType.id)), + ), + deselectOptionalNodeTypes: () => + setSelectedNodeTypeIds(new Set([...requiredNodeTypeIds])), + toggleNodeType: (nodeTypeId, shouldSelect) => { + if (!shouldSelect && requiredNodeTypeIds.has(nodeTypeId)) { + return { + ok: false, + reason: + "This node type is required by a selected relation triple. Remove the triple first.", + }; + } + setSelectedNodeTypeIds((previousSet) => + updateSet(previousSet, nodeTypeId, shouldSelect), + ); + return { ok: true }; + }, + selectAllRelationTypes: () => + setSelectedRelationTypeIds( + new Set(source.relationTypes.map((relationType) => relationType.id)), + ), + deselectOptionalRelationTypes: () => + setSelectedRelationTypeIds(new Set([...requiredRelationTypeIds])), + toggleRelationType: (relationTypeId, shouldSelect) => { + if (!shouldSelect && requiredRelationTypeIds.has(relationTypeId)) { + return { + ok: false, + reason: + "This relation type is required by a selected relation triple. Remove the triple first.", + }; + } + setSelectedRelationTypeIds((previousSet) => + updateSet(previousSet, relationTypeId, shouldSelect), + ); + return { ok: true }; + }, + selectAllRelationTriples: () => + setSelectedRelationIds( + new Set(source.relationTriples.map((relation) => relation.id)), + ), + deselectAllRelationTriples: () => setSelectedRelationIds(new Set()), + toggleRelationTriple: (relationId, shouldSelect) => + setSelectedRelationIds((previousSet) => + updateSet(previousSet, relationId, shouldSelect), + ), + selectAllTemplates: () => + setSelectedTemplateNames(new Set(source.templateNames)), + deselectAllTemplates: () => setSelectedTemplateNames(new Set()), + toggleTemplate: (templateName, shouldSelect) => + setSelectedTemplateNames((previousSet) => + updateSet(previousSet, templateName, shouldSelect), + ), + asSelectionPayload: () => ({ + nodeTypeIds: [...selectedNodeTypeIds], + relationTypeIds: [...selectedRelationTypeIds], + relationIds: [...selectedRelationIds], + templateNames: [...selectedTemplateNames], + }), + }; +}; diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index f7bc3fc41..a43ba9ae3 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -117,4 +117,20 @@ export type ImportFolderMetadata = { userName?: string; }; +export type DiscourseSchemaTemplate = { + name: string; + content: string; +}; + +export type DiscourseSchemaFile = { + version: number; + exportedAt: string; + pluginVersion: string; + vaultName: string; + nodeTypes: DiscourseNode[]; + relationTypes: DiscourseRelationType[]; + discourseRelations: DiscourseRelation[]; + templates: DiscourseSchemaTemplate[]; +}; + export const VIEW_TYPE_DISCOURSE_CONTEXT = "discourse-context-view"; diff --git a/apps/obsidian/src/utils/nativeJsonFileDialogs.ts b/apps/obsidian/src/utils/nativeJsonFileDialogs.ts new file mode 100644 index 000000000..9a784c61e --- /dev/null +++ b/apps/obsidian/src/utils/nativeJsonFileDialogs.ts @@ -0,0 +1,121 @@ +type SaveDialogResult = { + canceled: boolean; + filePath?: string; +}; + +type OpenDialogResult = { + canceled: boolean; + filePaths: string[]; +}; + +type ElectronDialog = { + showSaveDialog: (options: { + title: string; + defaultPath: string; + filters: Array<{ name: string; extensions: string[] }>; + }) => Promise; + showOpenDialog: (options: { + title: string; + properties: string[]; + filters: Array<{ name: string; extensions: string[] }>; + }) => Promise; +}; + +type ElectronLike = { + dialog?: ElectronDialog; + remote?: { + dialog?: ElectronDialog; + }; +}; + +type FsPromisesLike = { + readFile: (path: string, encoding: string) => Promise; + writeFile: (path: string, data: string, encoding: string) => Promise; +}; + +type ElectronWindow = Window & { + require: (name: string) => unknown; +}; + +export class NativeFileDialogCancelledError extends Error { + constructor() { + super("File dialog cancelled"); + this.name = "NativeFileDialogCancelledError"; + } +} + +const getElectronWindow = (): ElectronWindow => { + if (typeof window === "undefined" || !("require" in window)) { + throw new Error( + "Schema export/import requires Obsidian desktop (Electron).", + ); + } + return window as ElectronWindow; +}; + +const getFsPromises = (electronWindow: ElectronWindow): FsPromisesLike => { + const fsPromises = electronWindow.require("fs/promises"); + if ( + typeof fsPromises !== "object" || + fsPromises === null || + !("readFile" in fsPromises) || + !("writeFile" in fsPromises) + ) { + throw new Error("Unable to access filesystem read/write APIs."); + } + return fsPromises as FsPromisesLike; +}; + +const getElectronDialog = (electronWindow: ElectronWindow): ElectronDialog => { + const electron = electronWindow.require("electron") as ElectronLike; + const dialog = electron.dialog ?? electron.remote?.dialog; + if (!dialog?.showSaveDialog || !dialog.showOpenDialog) { + throw new Error("Unable to access Electron file dialogs."); + } + return dialog; +}; + +export const saveJsonToUserLocation = async ({ + title, + fileName, + content, +}: { + title: string; + fileName: string; + content: string; +}): Promise => { + const electronWindow = getElectronWindow(); + const dialog = getElectronDialog(electronWindow); + const result = await dialog.showSaveDialog({ + title, + defaultPath: fileName, + filters: [{ name: "JSON files", extensions: ["json"] }], + }); + if (result.canceled || !result.filePath) { + throw new NativeFileDialogCancelledError(); + } + const fsPromises = getFsPromises(electronWindow); + await fsPromises.writeFile(result.filePath, content, "utf8"); + return result.filePath; +}; + +export const openJsonFromUserLocation = async ({ + title, +}: { + title: string; +}): Promise<{ content: string; sourcePath: string }> => { + const electronWindow = getElectronWindow(); + const dialog = getElectronDialog(electronWindow); + const result = await dialog.showOpenDialog({ + title, + properties: ["openFile"], + filters: [{ name: "JSON files", extensions: ["json"] }], + }); + if (result.canceled || !result.filePaths[0]) { + throw new NativeFileDialogCancelledError(); + } + const fsPromises = getFsPromises(electronWindow); + const sourcePath = result.filePaths[0]; + const content = await fsPromises.readFile(sourcePath, "utf8"); + return { content, sourcePath }; +}; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index ea7e019f6..91026dd70 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -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"; @@ -14,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; @@ -194,6 +196,22 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); + plugin.addCommand({ + id: "export-dg-schema", + name: "Export discourse graph schema", + 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", diff --git a/apps/obsidian/src/utils/specExport.ts b/apps/obsidian/src/utils/specExport.ts new file mode 100644 index 000000000..6c70408c7 --- /dev/null +++ b/apps/obsidian/src/utils/specExport.ts @@ -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 = (items: T[]): Map => { + 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 => { + 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 }; +}; diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts new file mode 100644 index 000000000..2335b110e --- /dev/null +++ b/apps/obsidian/src/utils/specImport.ts @@ -0,0 +1,416 @@ +import type DiscourseGraphPlugin from "~/index"; +import { uuidv7 } from "uuidv7"; +import { parseDgSchemaFile } from "~/utils/specValidation"; +import { createTemplateFile, getTemplateFiles } from "~/utils/templates"; +import { openJsonFromUserLocation } from "~/utils/nativeJsonFileDialogs"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, + DiscourseSchemaFile, +} from "~/types"; +import { toTldrawColor } from "~/utils/tldrawColors"; + +export type SchemaImportMatchPlan = { + nodeTypeIdMapping: Map; + relationTypeIdMapping: Map; + existingNodeTypeIds: Set; + existingRelationTypeIds: Set; + existingDiscourseRelationIds: Set; + existingTemplateNames: Set; +}; + +export type LoadedSchemaFile = { + sourcePath: string; + schemaFile: DiscourseSchemaFile; + matchPlan: SchemaImportMatchPlan; +}; + +export type ImportPreviewStats = { + nodeTypes: { total: number; new: number; existing: number }; + relationTypes: { total: number; new: number; existing: number }; + discourseRelations: { total: number; new: number; existing: number }; + templates: { total: number; new: number; existing: number }; +}; + +export type SpecImportPreview = { + loadedSchemaFile: LoadedSchemaFile; + previewStats: ImportPreviewStats; +}; + +export type SpecImportSelection = { + nodeTypeIds: string[]; + relationTypeIds: string[]; + discourseRelationIds: string[]; + templateNames: string[]; +}; + +export type SpecImportApplyResult = { + created: { + nodeTypes: number; + relationTypes: number; + discourseRelations: number; + templates: number; + }; + warnings: string[]; +}; + +const normalizeLabel = (value: string): string => { + return value.trim().toLowerCase(); +}; + +const buildTripleKey = ({ + sourceId, + relationshipTypeId, + destinationId, +}: { + sourceId: string; + relationshipTypeId: string; + destinationId: string; +}): string => { + return `${sourceId}::${relationshipTypeId}::${destinationId}`; +}; + +const buildSchemaImportMatchPlan = ({ + schemaFile, + localNodeTypes, + localRelationTypes, + localDiscourseRelations, + localTemplateNames, +}: { + schemaFile: DiscourseSchemaFile; + localNodeTypes: DiscourseNode[]; + localRelationTypes: DiscourseRelationType[]; + localDiscourseRelations: DiscourseRelation[]; + localTemplateNames: Set; +}): SchemaImportMatchPlan => { + const localNodeTypeById = new Map( + localNodeTypes.map((nodeType) => [nodeType.id, nodeType]), + ); + const localNodeTypeByName = new Map( + localNodeTypes.map((nodeType) => [normalizeLabel(nodeType.name), nodeType]), + ); + const localRelationTypeById = new Map( + localRelationTypes.map((relationType) => [relationType.id, relationType]), + ); + const localRelationTypeByLabel = new Map( + localRelationTypes.map((relationType) => [ + normalizeLabel(relationType.label), + relationType, + ]), + ); + + const nodeTypeIdMapping = new Map(); + const existingNodeTypeIds = new Set(); + + for (const nodeType of schemaFile.nodeTypes) { + const matchById = localNodeTypeById.get(nodeType.id); + if (matchById) { + nodeTypeIdMapping.set(nodeType.id, matchById.id); + existingNodeTypeIds.add(nodeType.id); + continue; + } + + const matchByName = localNodeTypeByName.get(normalizeLabel(nodeType.name)); + if (matchByName) { + nodeTypeIdMapping.set(nodeType.id, matchByName.id); + existingNodeTypeIds.add(nodeType.id); + continue; + } + + nodeTypeIdMapping.set(nodeType.id, nodeType.id); + } + + const relationTypeIdMapping = new Map(); + const existingRelationTypeIds = new Set(); + + for (const relationType of schemaFile.relationTypes) { + const matchById = localRelationTypeById.get(relationType.id); + if (matchById) { + relationTypeIdMapping.set(relationType.id, matchById.id); + existingRelationTypeIds.add(relationType.id); + continue; + } + + const matchByLabel = localRelationTypeByLabel.get( + normalizeLabel(relationType.label), + ); + if (matchByLabel) { + relationTypeIdMapping.set(relationType.id, matchByLabel.id); + existingRelationTypeIds.add(relationType.id); + continue; + } + + relationTypeIdMapping.set(relationType.id, relationType.id); + } + + const localTripleKeys = new Set( + localDiscourseRelations.map((relation) => + buildTripleKey({ + sourceId: relation.sourceId, + relationshipTypeId: relation.relationshipTypeId, + destinationId: relation.destinationId, + }), + ), + ); + + const existingDiscourseRelationIds = new Set(); + for (const relation of schemaFile.discourseRelations) { + const mappedSourceId = + nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; + const mappedDestinationId = + nodeTypeIdMapping.get(relation.destinationId) ?? relation.destinationId; + const mappedRelationTypeId = + relationTypeIdMapping.get(relation.relationshipTypeId) ?? + relation.relationshipTypeId; + const key = buildTripleKey({ + sourceId: mappedSourceId, + relationshipTypeId: mappedRelationTypeId, + destinationId: mappedDestinationId, + }); + if (localTripleKeys.has(key)) { + existingDiscourseRelationIds.add(relation.id); + } + } + + const existingTemplateNames = new Set(); + for (const template of schemaFile.templates) { + if (localTemplateNames.has(template.name)) { + existingTemplateNames.add(template.name); + } + } + + return { + nodeTypeIdMapping, + relationTypeIdMapping, + existingNodeTypeIds, + existingRelationTypeIds, + existingDiscourseRelationIds, + existingTemplateNames, + }; +}; + +const buildPreviewStats = ({ + schemaFile, + matchPlan, +}: { + schemaFile: DiscourseSchemaFile; + matchPlan: SchemaImportMatchPlan; +}): ImportPreviewStats => { + return { + nodeTypes: { + total: schemaFile.nodeTypes.length, + existing: matchPlan.existingNodeTypeIds.size, + new: schemaFile.nodeTypes.length - matchPlan.existingNodeTypeIds.size, + }, + relationTypes: { + total: schemaFile.relationTypes.length, + existing: matchPlan.existingRelationTypeIds.size, + new: + schemaFile.relationTypes.length - + matchPlan.existingRelationTypeIds.size, + }, + discourseRelations: { + total: schemaFile.discourseRelations.length, + existing: matchPlan.existingDiscourseRelationIds.size, + new: + schemaFile.discourseRelations.length - + matchPlan.existingDiscourseRelationIds.size, + }, + templates: { + total: schemaFile.templates.length, + existing: matchPlan.existingTemplateNames.size, + new: schemaFile.templates.length - matchPlan.existingTemplateNames.size, + }, + }; +}; + +export const pickAndPreviewSchemaImport = async ({ + plugin, +}: { + plugin: DiscourseGraphPlugin; +}): Promise => { + const file = await openJsonFromUserLocation({ + title: "Import discourse graph schema", + }); + const schemaFile = parseDgSchemaFile(JSON.parse(file.content) as unknown); + const localTemplateNames = new Set(getTemplateFiles(plugin.app)); + const matchPlan = buildSchemaImportMatchPlan({ + schemaFile, + localNodeTypes: plugin.settings.nodeTypes, + localRelationTypes: plugin.settings.relationTypes, + localDiscourseRelations: plugin.settings.discourseRelations, + localTemplateNames, + }); + + const loadedSchemaFile: LoadedSchemaFile = { + sourcePath: file.sourcePath, + schemaFile, + matchPlan, + }; + + return { + loadedSchemaFile, + previewStats: buildPreviewStats({ schemaFile, matchPlan }), + }; +}; + +export const applySchemaImportSelection = async ({ + plugin, + loadedSchemaFile, + selection, +}: { + plugin: DiscourseGraphPlugin; + loadedSchemaFile: LoadedSchemaFile; + selection: SpecImportSelection; +}): Promise => { + const warnings: string[] = []; + const { schemaFile, matchPlan } = loadedSchemaFile; + const selectedTemplateNames = new Set(selection.templateNames); + const selectedNodeTypeIds = new Set(selection.nodeTypeIds); + const selectedRelationTypeIds = new Set(selection.relationTypeIds); + const selectedRelationIds = new Set(selection.discourseRelationIds); + + let templatesCreated = 0; + const templatesByName = new Map( + schemaFile.templates.map((template) => [template.name, template]), + ); + for (const templateName of selectedTemplateNames) { + if (matchPlan.existingTemplateNames.has(templateName)) { + continue; + } + + const template = templatesByName.get(templateName); + if (!template) { + warnings.push( + `Template "${templateName}" was selected but not found in schema file.`, + ); + continue; + } + + const result = await createTemplateFile({ + app: plugin.app, + templateName: template.name, + content: template.content, + }); + + if (result.created) { + templatesCreated += 1; + continue; + } + + if (result.reason !== "template already exists") { + warnings.push(`Template "${template.name}" skipped: ${result.reason}.`); + } + } + + const schemaNodeTypesById = new Map( + schemaFile.nodeTypes.map((nodeType) => [nodeType.id, nodeType]), + ); + const schemaRelationTypesById = new Map( + schemaFile.relationTypes.map((relationType) => [ + relationType.id, + relationType, + ]), + ); + + let nodeTypesCreated = 0; + for (const nodeTypeId of selectedNodeTypeIds) { + if (matchPlan.existingNodeTypeIds.has(nodeTypeId)) { + continue; + } + + const importedNodeType = schemaNodeTypesById.get(nodeTypeId); + if (!importedNodeType) { + warnings.push( + `Node type "${nodeTypeId}" was selected but missing from schema file.`, + ); + continue; + } + + const newNodeType: DiscourseNode = { + ...importedNodeType, + template: + importedNodeType.template && + selectedTemplateNames.has(importedNodeType.template) + ? importedNodeType.template + : undefined, + modified: Date.now(), + }; + plugin.settings.nodeTypes = [...plugin.settings.nodeTypes, newNodeType]; + nodeTypesCreated += 1; + } + + let relationTypesCreated = 0; + for (const relationTypeId of selectedRelationTypeIds) { + if (matchPlan.existingRelationTypeIds.has(relationTypeId)) { + continue; + } + + const importedRelationType = schemaRelationTypesById.get(relationTypeId); + if (!importedRelationType) { + warnings.push( + `Relation type "${relationTypeId}" was selected but missing from schema file.`, + ); + continue; + } + + const newRelationType: DiscourseRelationType = { + ...importedRelationType, + color: toTldrawColor(importedRelationType.color), + status: "provisional", + modified: Date.now(), + }; + plugin.settings.relationTypes = [ + ...plugin.settings.relationTypes, + newRelationType, + ]; + relationTypesCreated += 1; + } + + let discourseRelationsCreated = 0; + for (const relation of schemaFile.discourseRelations) { + if (!selectedRelationIds.has(relation.id)) { + continue; + } + if (matchPlan.existingDiscourseRelationIds.has(relation.id)) { + continue; + } + + const mappedSourceId = + matchPlan.nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; + const mappedDestinationId = + matchPlan.nodeTypeIdMapping.get(relation.destinationId) ?? + relation.destinationId; + const mappedRelationTypeId = + matchPlan.relationTypeIdMapping.get(relation.relationshipTypeId) ?? + relation.relationshipTypeId; + + const newRelation: DiscourseRelation = { + ...relation, + id: uuidv7(), + sourceId: mappedSourceId, + destinationId: mappedDestinationId, + relationshipTypeId: mappedRelationTypeId, + status: "provisional", + modified: Date.now(), + }; + plugin.settings.discourseRelations = [ + ...plugin.settings.discourseRelations, + newRelation, + ]; + discourseRelationsCreated += 1; + } + + await plugin.saveSettings(); + + return { + created: { + nodeTypes: nodeTypesCreated, + relationTypes: relationTypesCreated, + discourseRelations: discourseRelationsCreated, + templates: templatesCreated, + }, + warnings, + }; +}; diff --git a/apps/obsidian/src/utils/specValidation.ts b/apps/obsidian/src/utils/specValidation.ts new file mode 100644 index 000000000..b9662e548 --- /dev/null +++ b/apps/obsidian/src/utils/specValidation.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; +import type { DiscourseSchemaFile } from "~/types"; + +export const DG_SCHEMA_EXPORT_VERSION = 1; + +const discourseNodeSchema = z.object({ + id: z.string(), + name: z.string(), + format: z.string(), + template: z.string().optional(), + description: z.string().optional(), + shortcut: z.string().optional(), + color: z.string().optional(), + tag: z.string().optional(), + keyImage: z.boolean().optional(), + folderPath: z.string().optional(), + created: z.number(), + modified: z.number(), + importedFromRid: z.string().optional(), + authorId: z.number().optional(), +}); + +const relationImportStatusSchema = z.enum(["provisional", "accepted"]); + +const discourseRelationTypeSchema = z.object({ + id: z.string(), + label: z.string(), + complement: z.string(), + color: z.string(), + created: z.number(), + modified: z.number(), + importedFromRid: z.string().optional(), + status: relationImportStatusSchema.optional(), + authorId: z.number().optional(), +}); + +const discourseRelationSchema = z.object({ + id: z.string(), + sourceId: z.string(), + destinationId: z.string(), + relationshipTypeId: z.string(), + created: z.number(), + modified: z.number(), + importedFromRid: z.string().optional(), + status: relationImportStatusSchema.optional(), + authorId: z.number().optional(), +}); + +const templateExportSchema = z.object({ + name: z.string(), + content: z.string(), +}); + +export const dgSchemaFileSchema = z.object({ + version: z.literal(DG_SCHEMA_EXPORT_VERSION), + exportedAt: z.string(), + pluginVersion: z.string(), + vaultName: z.string(), + nodeTypes: z.array(discourseNodeSchema), + relationTypes: z.array(discourseRelationTypeSchema), + discourseRelations: z.array(discourseRelationSchema), + templates: z.array(templateExportSchema), +}); + +const normalizeToKebabCase = (value: string): string => { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); +}; + +export const getDgSchemaFileName = (vaultName?: string): string => { + const normalizedVaultName = vaultName ? normalizeToKebabCase(vaultName) : ""; + const safeVaultName = + normalizedVaultName.length > 0 ? normalizedVaultName : "vault"; + return `dg-schema-${safeVaultName}.json`; +}; + +export const parseDgSchemaFile = (value: unknown): DiscourseSchemaFile => { + return dgSchemaFileSchema.parse(value) as DiscourseSchemaFile; +};