diff --git a/apps/obsidian/src/components/ConfirmationModal.tsx b/apps/obsidian/src/components/ConfirmationModal.tsx new file mode 100644 index 000000000..581595314 --- /dev/null +++ b/apps/obsidian/src/components/ConfirmationModal.tsx @@ -0,0 +1,55 @@ +import { App, Modal } from "obsidian"; + +interface ConfirmationModalProps { + title: string; + message: string; + onConfirm: () => void; +} + +export class ConfirmationModal extends Modal { + private title: string; + private message: string; + private onConfirm: () => void; + + constructor(app: App, { title, message, onConfirm }: ConfirmationModalProps) { + super(app); + this.title = title; + this.message = message; + this.onConfirm = onConfirm; + } + + onOpen() { + const { contentEl } = this; + + contentEl.createEl("h2", { text: this.title }); + contentEl.createEl("p", { text: this.message }); + + const buttonContainer = contentEl.createDiv({ + cls: "modal-button-container", + }); + + buttonContainer + .createEl("button", { + text: "Cancel", + cls: "mod-normal", + }) + .addEventListener("click", () => { + this.close(); + }); + + buttonContainer + .createEl("button", { + text: "Confirm", + cls: "mod-warning", + }) + .addEventListener("click", () => { + this.onConfirm(); + this.close(); + }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx new file mode 100644 index 000000000..f6cf97d9a --- /dev/null +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -0,0 +1,200 @@ +import { useState } from "react"; +import { + validateAllNodes, + validateNodeFormat, + validateNodeName, +} from "~/utils/validateNodeType"; +import { usePlugin } from "./PluginContext"; +import { Notice } from "obsidian"; +import generateUid from "~/utils/generateUid"; +import { DiscourseNode } from "~/types"; +import { ConfirmationModal } from "./ConfirmationModal"; + +const NodeTypeSettings = () => { + const plugin = usePlugin(); + const [nodeTypes, setNodeTypes] = useState( + () => plugin.settings.nodeTypes ?? [], + ); + const [formatErrors, setFormatErrors] = useState>({}); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + const updateErrors = ( + index: number, + validation: { isValid: boolean; error?: string }, + ) => { + if (!validation.isValid) { + setFormatErrors((prev) => ({ + ...prev, + [index]: validation.error || "Invalid input", + })); + } else { + setFormatErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[index]; + return newErrors; + }); + } + }; + + const handleNodeTypeChange = async ( + index: number, + field: keyof DiscourseNode, + value: string, + ): Promise => { + const updatedNodeTypes = [...nodeTypes]; + if (!updatedNodeTypes[index]) { + const newId = generateUid("node"); + updatedNodeTypes[index] = { id: newId, name: "", format: "" }; + } + + updatedNodeTypes[index][field] = value; + + if (field === "format") { + const { isValid, error } = validateNodeFormat(value, updatedNodeTypes); + updateErrors(index, { isValid, error }); + } else if (field === "name") { + const nameValidation = validateNodeName(value, updatedNodeTypes); + updateErrors(index, nameValidation); + } + + setNodeTypes(updatedNodeTypes); + setHasUnsavedChanges(true); + }; + + const handleAddNodeType = (): void => { + const newId = generateUid("node"); + const updatedNodeTypes = [ + ...nodeTypes, + { + id: newId, + name: "", + format: "", + }, + ]; + setNodeTypes(updatedNodeTypes); + setHasUnsavedChanges(true); + }; + + const confirmDeleteNodeType = (index: number): void => { + const nodeType = nodeTypes[index] || { name: "Unnamed" }; + const modal = new ConfirmationModal(plugin.app, { + title: "Delete Node Type", + message: `Are you sure you want to delete the node type "${nodeType.name}"?`, + onConfirm: () => handleDeleteNodeType(index), + }); + modal.open(); + }; + + const handleDeleteNodeType = async (index: number): Promise => { + const nodeId = nodeTypes[index]?.id; + const isUsed = plugin.settings.discourseRelations?.some( + (rel) => rel.sourceId === nodeId || rel.destinationId === nodeId, + ); + + if (isUsed) { + new Notice( + "Cannot delete this node type as it is used in one or more relations.", + ); + return; + } + + const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index); + setNodeTypes(updatedNodeTypes); + plugin.settings.nodeTypes = updatedNodeTypes; + await plugin.saveSettings(); + if (formatErrors[index]) { + setFormatErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[index]; + return newErrors; + }); + } + new Notice("Node type deleted successfully"); + }; + + const handleSave = async (): Promise => { + const { hasErrors, errorMap } = validateAllNodes(nodeTypes); + + if (hasErrors) { + setFormatErrors(errorMap); + new Notice("Please fix the errors before saving"); + return; + } + plugin.settings.nodeTypes = nodeTypes; + await plugin.saveSettings(); + new Notice("Node types saved"); + setHasUnsavedChanges(false); + }; + + return ( +
+

Node Types

+ {nodeTypes.map((nodeType, index) => ( +
+
+
+ + handleNodeTypeChange(index, "name", e.target.value) + } + style={{ flex: 1 }} + /> + + handleNodeTypeChange(index, "format", e.target.value) + } + style={{ flex: 2 }} + /> + +
+ {formatErrors[index] && ( +
+ {formatErrors[index]} +
+ )} +
+
+ ))} +
+
+ + +
+
+ {hasUnsavedChanges && ( +
+ You have unsaved changes +
+ )} +
+ ); +}; + +export default NodeTypeSettings; diff --git a/apps/obsidian/src/components/PluginContext.tsx b/apps/obsidian/src/components/PluginContext.tsx new file mode 100644 index 000000000..258245259 --- /dev/null +++ b/apps/obsidian/src/components/PluginContext.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext, ReactNode } from "react"; +import type DiscourseGraphPlugin from "~/index"; + +export const PluginContext = createContext( + undefined, +); + +export const usePlugin = (): DiscourseGraphPlugin => { + const plugin = useContext(PluginContext); + if (!plugin) { + throw new Error("usePlugin must be used within a PluginProvider"); + } + return plugin; +}; + +export const PluginProvider = ({ + plugin, + children, +}: { + plugin: DiscourseGraphPlugin; + children: ReactNode; +}) => { + return ( + {children} + ); +}; diff --git a/apps/obsidian/src/components/RelationshipSettings.tsx b/apps/obsidian/src/components/RelationshipSettings.tsx new file mode 100644 index 000000000..12a507371 --- /dev/null +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -0,0 +1,306 @@ +import { useState } from "react"; +import { + DiscourseRelation, + DiscourseNode, + DiscourseRelationType, +} from "~/types"; +import { Notice } from "obsidian"; +import { usePlugin } from "./PluginContext"; +import { ConfirmationModal } from "./ConfirmationModal"; + +const RelationshipSettings = () => { + const plugin = usePlugin(); + const [discourseRelations, setDiscourseRelations] = useState< + DiscourseRelation[] + >(() => plugin.settings.discourseRelations ?? []); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + const findNodeById = (id: string): DiscourseNode | undefined => { + return plugin.settings.nodeTypes.find((node) => node.id === id); + }; + + const findRelationTypeById = ( + id: string, + ): DiscourseRelationType | undefined => { + return plugin.settings.relationTypes.find((relType) => relType.id === id); + }; + + const handleRelationChange = async ( + index: number, + field: keyof DiscourseRelation, + value: string, + ): Promise => { + const updatedRelations = [...discourseRelations]; + + if (!updatedRelations[index]) { + updatedRelations[index] = { + sourceId: "", + destinationId: "", + relationshipTypeId: "", + }; + } + + updatedRelations[index][field] = value; + setDiscourseRelations(updatedRelations); + setHasUnsavedChanges(true); + }; + + const handleAddRelation = (): void => { + const updatedRelations = [ + ...discourseRelations, + { + sourceId: "", + destinationId: "", + relationshipTypeId: "", + }, + ]; + setDiscourseRelations(updatedRelations); + setHasUnsavedChanges(true); + }; + + const confirmDeleteRelation = (index: number): void => { + const relation = discourseRelations[index] || { + sourceId: "", + destinationId: "", + relationshipTypeId: "", + }; + let message = "Are you sure you want to delete this relation?"; + + // If the relation has source and target nodes, provide more context + if ( + relation.sourceId && + relation.destinationId && + relation.relationshipTypeId + ) { + const sourceNode = findNodeById(relation.sourceId); + const targetNode = findNodeById(relation.destinationId); + const relationType = findRelationTypeById(relation.relationshipTypeId); + + if (sourceNode && targetNode && relationType) { + message = `Are you sure you want to delete the relation between "${sourceNode.name}" and "${targetNode.name}" (${relationType.label})?`; + } + } + + const modal = new ConfirmationModal(plugin.app, { + title: "Delete Relation", + message, + onConfirm: () => handleDeleteRelation(index), + }); + modal.open(); + }; + + const handleDeleteRelation = async (index: number): Promise => { + const updatedRelations = discourseRelations.filter((_, i) => i !== index); + setDiscourseRelations(updatedRelations); + plugin.settings.discourseRelations = updatedRelations; + await plugin.saveSettings(); + new Notice("Relation deleted"); + }; + + const handleSave = async (): Promise => { + for (const relation of discourseRelations) { + if ( + !relation.relationshipTypeId || + !relation.sourceId || + !relation.destinationId + ) { + new Notice("All fields are required for relations."); + return; + } + } + + const relationKeys = discourseRelations.map( + (r) => `${r.relationshipTypeId}-${r.sourceId}-${r.destinationId}`, + ); + if (new Set(relationKeys).size !== relationKeys.length) { + new Notice("Duplicate relations are not allowed."); + return; + } + + plugin.settings.discourseRelations = discourseRelations; + await plugin.saveSettings(); + new Notice("Relations saved"); + setHasUnsavedChanges(false); + }; + + return ( +
+

Node Type Relations

+ + {plugin.settings.nodeTypes.length === 0 ? ( +
You need to create some node types first.
+ ) : plugin.settings.relationTypes.length === 0 ? ( +
You need to create some relation types first.
+ ) : ( + <> + {discourseRelations.map((relation, index) => ( +
+
+
+ + + + + + + +
+ + {relation.sourceId && + relation.relationshipTypeId && + relation.destinationId && ( +
+
+
+ {findNodeById(relation.sourceId)?.name || + "Unknown Node"} +
+ +
+
+
+ {findRelationTypeById(relation.relationshipTypeId) + ?.label || "Unknown Relation"} +
+
+ → +
+
+
+ ←{" "} + + {findRelationTypeById(relation.relationshipTypeId) + ?.complement || "Unknown Complement"} + +
+
+ +
+ {findNodeById(relation.destinationId)?.name || + "Unknown Node"} +
+
+
+ )} +
+
+ ))} +
+
+ + +
+
+ {hasUnsavedChanges && ( +
+ You have unsaved changes +
+ )} + + )} +
+ ); +}; + +export default RelationshipSettings; diff --git a/apps/obsidian/src/components/RelationshipTypeSettings.tsx b/apps/obsidian/src/components/RelationshipTypeSettings.tsx new file mode 100644 index 000000000..7b71ffb5b --- /dev/null +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -0,0 +1,162 @@ +import { useState } from "react"; +import { DiscourseRelationType } from "~/types"; +import { Notice } from "obsidian"; +import { usePlugin } from "./PluginContext"; +import generateUid from "~/utils/generateUid"; +import { ConfirmationModal } from "./ConfirmationModal"; + +const RelationshipTypeSettings = () => { + const plugin = usePlugin(); + const [relationTypes, setRelationTypes] = useState( + () => plugin.settings.relationTypes ?? [], + ); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + const handleRelationTypeChange = async ( + index: number, + field: keyof DiscourseRelationType, + value: string, + ): Promise => { + const updatedRelationTypes = [...relationTypes]; + if (!updatedRelationTypes[index]) { + const newId = generateUid("rel"); + updatedRelationTypes[index] = { id: newId, label: "", complement: "" }; + } + + updatedRelationTypes[index][field] = value; + setRelationTypes(updatedRelationTypes); + setHasUnsavedChanges(true); + }; + + const handleAddRelationType = (): void => { + const newId = generateUid("rel"); + + const updatedRelationTypes = [ + ...relationTypes, + { + id: newId, + label: "", + complement: "", + }, + ]; + setRelationTypes(updatedRelationTypes); + setHasUnsavedChanges(true); + }; + + const confirmDeleteRelationType = (index: number): void => { + const relationType = relationTypes[index] || { + label: "Unnamed", + complement: "", + }; + const modal = new ConfirmationModal(plugin.app, { + title: "Delete Relation Type", + message: `Are you sure you want to delete the relation type "${relationType.label}"?`, + onConfirm: () => handleDeleteRelationType(index), + }); + modal.open(); + }; + + const handleDeleteRelationType = async (index: number): Promise => { + const isUsed = plugin.settings.discourseRelations?.some( + (rel) => rel.relationshipTypeId === relationTypes[index]?.id, + ); + + if (isUsed) { + new Notice( + "Cannot delete this relation type as it is used in one or more relations.", + ); + return; + } + + const updatedRelationTypes = relationTypes.filter((_, i) => i !== index); + setRelationTypes(updatedRelationTypes); + plugin.settings.relationTypes = updatedRelationTypes; + await plugin.saveSettings(); + new Notice("Relation type deleted successfully"); + }; + + const handleSave = async (): Promise => { + for (const relType of relationTypes) { + if (!relType.id || !relType.label || !relType.complement) { + new Notice("All fields are required for relation types."); + return; + } + } + + const labels = relationTypes.map((rt) => rt.label); + if (new Set(labels).size !== labels.length) { + new Notice("Relation type labels must be unique."); + return; + } + + const complements = relationTypes.map((rt) => rt.complement); + if (new Set(complements).size !== complements.length) { + new Notice("Relation type complements must be unique."); + return; + } + + plugin.settings.relationTypes = relationTypes; + await plugin.saveSettings(); + setHasUnsavedChanges(false); + new Notice("Relation types saved."); + }; + + return ( +
+

Relation Types

+ {relationTypes.map((relationType, index) => ( +
+
+
+ + handleRelationTypeChange(index, "label", e.target.value) + } + style={{ flex: 1 }} + /> + + handleRelationTypeChange(index, "complement", e.target.value) + } + style={{ flex: 2 }} + /> + +
+
+
+ ))} +
+
+ + +
+
+ {hasUnsavedChanges && ( +
+ You have unsaved changes +
+ )} +
+ ); +}; + +export default RelationshipTypeSettings; diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 89d3af77a..715425fdb 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -1,181 +1,91 @@ -import { StrictMode, useState, useEffect } from "react"; +import { StrictMode, useState } from "react"; import { App, PluginSettingTab } from "obsidian"; -import type DiscourseGraphPlugin from "../index"; +import type DiscourseGraphPlugin from "~/index"; import { Root, createRoot } from "react-dom/client"; -import { ContextProvider, useApp } from "./AppContext"; -import { validateNodeFormat } from "../utils/validateNodeFormat"; +import { ContextProvider } from "./AppContext"; +import RelationshipTypeSettings from "./RelationshipTypeSettings"; +import RelationshipSettings from "./RelationshipSettings"; +import NodeTypeSettings from "./NodeTypeSettings"; +import { PluginProvider } from "./PluginContext"; -const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { - const [nodeTypes, setNodeTypes] = useState( - () => plugin.settings.nodeTypes ?? [], - ); - const [formatErrors, setFormatErrors] = useState>({}); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - - useEffect(() => { - const initializeSettings = async () => { - let needsSave = false; - - if (!plugin.settings.nodeTypes) { - plugin.settings.nodeTypes = []; - needsSave = true; - } - - if (needsSave) { - await plugin.saveSettings(); - } - }; - - initializeSettings(); - }, [plugin]); - - const handleNodeTypeChange = async ( - index: number, - field: "name" | "format", - value: string, - ): Promise => { - const updatedNodeTypes = [...nodeTypes]; - if (!updatedNodeTypes[index]) { - updatedNodeTypes[index] = { name: "", format: "" }; - } - - updatedNodeTypes[index][field] = value; - setNodeTypes(updatedNodeTypes); - setHasUnsavedChanges(true); - - if (field === "format") { - const { isValid, error } = validateNodeFormat(value); - if (!isValid) { - setFormatErrors((prev) => ({ - ...prev, - [index]: error ?? "Invalid format", - })); - } else { - setFormatErrors((prev) => { - const newErrors = { ...prev }; - delete newErrors[index]; - return newErrors; - }); - } - } - }; - - const handleAddNodeType = (): void => { - const updatedNodeTypes = [ - ...nodeTypes, - { - name: "", - format: "", - }, - ]; - setNodeTypes(updatedNodeTypes); - setHasUnsavedChanges(true); - }; - - const handleDeleteNodeType = async (index: number): Promise => { - const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index); - setNodeTypes(updatedNodeTypes); - plugin.settings.nodeTypes = updatedNodeTypes; - await plugin.saveSettings(); - }; - - const handleSave = async (): Promise => { - let hasErrors = false; - for (let i = 0; i < nodeTypes.length; i++) { - const { isValid, error } = validateNodeFormat(nodeTypes[i]?.format ?? ""); - if (!isValid) { - setFormatErrors((prev) => ({ - ...prev, - [i]: error ?? "Invalid format", - })); - hasErrors = true; - } - } - - if (hasErrors) { - return; - } - - plugin.settings.nodeTypes = nodeTypes; - await plugin.saveSettings(); - setHasUnsavedChanges(false); - }; +const Settings = () => { + const [activeTab, setActiveTab] = useState("nodeTypes"); return ( -
-

Node Types

- {nodeTypes.map((nodeType, index) => ( -
-
-
- - handleNodeTypeChange(index, "name", e.target.value) - } - style={{ flex: 1 }} - /> - - handleNodeTypeChange(index, "format", e.target.value) - } - style={{ flex: 2 }} - /> - -
- {formatErrors[index] && ( -
- {formatErrors[index]} -
- )} -
-
- ))} -
-
- - -
+
+

Discourse Graph Settings

+ +
+ + +
- {hasUnsavedChanges && ( -
- You have unsaved changes -
- )} -
- ); -}; -const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { - return ( -
- + {activeTab === "nodeTypes" && } + {activeTab === "relationTypes" && } + {activeTab === "relations" && }
); }; @@ -197,7 +107,9 @@ export class SettingsTab extends PluginSettingTab { this.root.render( - + + + , ); @@ -209,4 +121,4 @@ export class SettingsTab extends PluginSettingTab { this.root = null; } } -} \ No newline at end of file +} diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index ca0598fbd..39586a0f7 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -5,6 +5,8 @@ import { registerCommands } from "./utils/registerCommands"; const DEFAULT_SETTINGS: Settings = { nodeTypes: [], + discourseRelations: [], + relationTypes: [], }; export default class DiscourseGraphPlugin extends Plugin { diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css new file mode 100644 index 000000000..e69de29bb diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index ff01b00e3..fae4c112a 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -1,10 +1,25 @@ export type DiscourseNode = { + id: string; name: string; format: string; shortcut?: string; color?: string; }; +export type DiscourseRelationType = { + id: string; + label: string; + complement: string; +}; + +export type DiscourseRelation = { + sourceId: string; + destinationId: string; + relationshipTypeId: string; +}; + export type Settings = { nodeTypes: DiscourseNode[]; + discourseRelations: DiscourseRelation[]; + relationTypes: DiscourseRelationType[]; }; diff --git a/apps/obsidian/src/utils/generateUid.ts b/apps/obsidian/src/utils/generateUid.ts new file mode 100644 index 000000000..ae6c9f442 --- /dev/null +++ b/apps/obsidian/src/utils/generateUid.ts @@ -0,0 +1,7 @@ +import { nanoid } from "nanoid"; + +const generateUid = (prefix = "dg") => { + return `${prefix}_${nanoid()}`; +}; + +export default generateUid; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 5aa566dee..1f9fa566e 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -1,11 +1,4 @@ -import { - Editor, - MarkdownFileInfo, - MarkdownView, - App, - Notice, - SuggestModal, -} from "obsidian"; +import { Editor, MarkdownView, MarkdownFileInfo } from "obsidian"; import { SampleModal } from "~/components/SampleModal"; import type DiscourseGraphPlugin from "~/index"; import { NodeTypeModal } from "~/components/NodeTypeModal"; @@ -30,7 +23,6 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); - // This adds a complex command that can check whether the current state of the app allows execution of the command plugin.addCommand({ id: "open-sample-modal-complex", diff --git a/apps/obsidian/src/utils/validateNodeFormat.ts b/apps/obsidian/src/utils/validateNodeFormat.ts index da73e4afd..5e03281b8 100644 --- a/apps/obsidian/src/utils/validateNodeFormat.ts +++ b/apps/obsidian/src/utils/validateNodeFormat.ts @@ -1,7 +1,9 @@ -export function validateNodeFormat(format: string): { +export const validateNodeFormat = ( + format: string, +): { isValid: boolean; error?: string; -} { +} => { if (!format) { return { isValid: false, @@ -25,4 +27,4 @@ export function validateNodeFormat(format: string): { } return { isValid: true }; -} +}; diff --git a/apps/obsidian/src/utils/validateNodeType.ts b/apps/obsidian/src/utils/validateNodeType.ts new file mode 100644 index 000000000..816cb9874 --- /dev/null +++ b/apps/obsidian/src/utils/validateNodeType.ts @@ -0,0 +1,100 @@ +import { DiscourseNode } from "~/types"; + +export function validateNodeFormat( + format: string, + nodeTypes: DiscourseNode[], +): { + isValid: boolean; + error?: string; +} { + if (!format) { + return { + isValid: false, + error: "Format cannot be empty", + }; + } + + if (format.includes("[[") || format.includes("]]")) { + return { + isValid: false, + error: "Format should not contain double brackets [[ or ]]", + }; + } + + if (!format.includes("{content}")) { + return { + isValid: false, + error: 'Format must include the placeholder "{content}"', + }; + } + + const { isValid, error } = validateFormatUniqueness(nodeTypes); + if (!isValid) { + return { isValid: false, error }; + } + + return { isValid: true }; +} + +const validateFormatUniqueness = ( + nodeTypes: DiscourseNode[], +): { isValid: boolean; error?: string } => { + const isDuplicate = + new Set(nodeTypes.map((nodeType) => nodeType.format)).size !== + nodeTypes.length; + + if (isDuplicate) { + return { isValid: false, error: "Format must be unique" }; + } + + return { isValid: true }; +}; + +export const validateNodeName = ( + name: string, + nodeTypes: DiscourseNode[], +): { isValid: boolean; error?: string } => { + if (!name || name.trim() === "") { + return { isValid: false, error: "Name is required" }; + } + + const isDuplicate = + new Set(nodeTypes.map((nodeType) => nodeType.name)).size !== + nodeTypes.length; + + if (isDuplicate) { + return { isValid: false, error: "Name must be unique" }; + } + + return { isValid: true }; +}; + +export const validateAllNodes = ( + nodeTypes: DiscourseNode[], +): { hasErrors: boolean; errorMap: Record } => { + const errorMap: Record = {}; + let hasErrors = false; + nodeTypes.forEach((nodeType, index) => { + if (!nodeType?.name || !nodeType?.format) { + errorMap[index] = "Name and format are required"; + hasErrors = true; + return; + } + + const formatValidation = validateNodeFormat(nodeType.format, nodeTypes); + if (!formatValidation.isValid) { + errorMap[index] = formatValidation.error || "Invalid format"; + hasErrors = true; + return; + } + + const nameValidation = validateNodeName(nodeType.name, nodeTypes); + if (!nameValidation.isValid) { + errorMap[index] = nameValidation.error || "Invalid name"; + hasErrors = true; + return; + } + }); + + return { hasErrors, errorMap }; +}; diff --git a/apps/obsidian/styles.css b/apps/obsidian/styles.css index 26f590cea..007500fb2 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -1,3 +1,8 @@ -.modal-content { - background-color: red; +.discourse-relations .relationship-visualization { + transition: all 0.2s ease; +} + +.discourse-relations .relationship-visualization:hover { + background: var(--background-modifier-hover); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } diff --git a/package-lock.json b/package-lock.json index 6f39f3de1..13c3a5728 100644 --- a/package-lock.json +++ b/package-lock.json @@ -702,7 +702,7 @@ } }, "apps/roam": { - "version": "0.10.2", + "version": "0.11.0", "hasInstallScript": true, "license": "MIT", "dependencies": {