From 8d968e14c7d28d876edaf1d22de33720886d9f63 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 12 Feb 2025 22:23:13 -0600 Subject: [PATCH 01/23] Rename trang.JPG to trang.jpg --- apps/website/public/team/trang.jpg | 1 + 1 file changed, 1 insertion(+) create mode 100755 apps/website/public/team/trang.jpg diff --git a/apps/website/public/team/trang.jpg b/apps/website/public/team/trang.jpg new file mode 100755 index 000000000..d3f5a12fa --- /dev/null +++ b/apps/website/public/team/trang.jpg @@ -0,0 +1 @@ + From d4c636b4cb46c65e3b9d9e285d9a0aca2b4379a3 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 12 Feb 2025 22:35:30 -0600 Subject: [PATCH 02/23] remove trang.jpg --- apps/website/public/team/trang.jpg | 1 - 1 file changed, 1 deletion(-) delete mode 100755 apps/website/public/team/trang.jpg diff --git a/apps/website/public/team/trang.jpg b/apps/website/public/team/trang.jpg deleted file mode 100755 index d3f5a12fa..000000000 --- a/apps/website/public/team/trang.jpg +++ /dev/null @@ -1 +0,0 @@ - From 95d426f4a4feeb12c36ddc878cca33e882723c7a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 17 Feb 2025 21:07:15 +0700 Subject: [PATCH 03/23] curr progress --- apps/obsidian/src/components/Settings.tsx | 144 ++++++++++++++++--- apps/obsidian/src/components/SettingsTab.tsx | 56 ++++++++ apps/obsidian/src/index.ts | 25 +++- apps/obsidian/src/types.ts | 10 ++ apps/obsidian/src/utils/registerCommands.ts | 2 + 5 files changed, 216 insertions(+), 21 deletions(-) create mode 100644 apps/obsidian/src/components/SettingsTab.tsx create mode 100644 apps/obsidian/src/types.ts diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index cfe4d098c..8944eb395 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -4,13 +4,117 @@ import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; -const Settings = () => { +const NodeTypeSettings = ({ + nodeTypes, + onNodeTypeChange, + onAddNodeType, +}: { + nodeTypes: Array<{ name: string; format: string }>; + onNodeTypeChange: ( + index: number, + field: "name" | "format", + value: string, + ) => Promise; + onAddNodeType: () => Promise; +}) => { + return ( +
+

Node Types

+ {nodeTypes.map((nodeType, index) => ( +
+
+ onNodeTypeChange(index, "name", e.target.value)} + style={{ flex: 1 }} + /> + + onNodeTypeChange(index, "format", e.target.value) + } + style={{ flex: 2 }} + /> +
+
+ ))} +
+ +
+
+ ); +}; + +const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { const app = useApp(); if (!app) { - return
An error occured
; + return
An error occurred
; + } + + // Initialize nodeTypes if undefined + if (!plugin.settings.nodeTypes) { + plugin.settings.nodeTypes = []; } - return

Settings for {app.vault.getName()}

; + const handleNodeTypeChange = async ( + index: number, + field: "name" | "format", + value: string, + ) => { + if (!plugin.settings.nodeTypes[index]) { + plugin.settings.nodeTypes[index] = { name: "", format: "" }; + } + plugin.settings.nodeTypes[index][field] = value; + await plugin.saveSettings(); + }; + + const handleAddNodeType = async () => { + plugin.settings.nodeTypes.push({ + name: "", + format: "", + }); + await plugin.saveSettings(); + }; +); + + + + return ( +
+

Discourse Graph Settings

+ {/* Original setting */} + +
+
+
Setting #1
+
It's a secret
+
+
+ { + plugin.settings.mySetting = e.target.value; + await plugin.saveSettings(); + }} + /> +
+
+
+ {/* Node Type Settings */} + +

Settings for {app.vault.getName()}

; +
+ ); }; export class SettingsTab extends PluginSettingTab { @@ -26,30 +130,30 @@ export class SettingsTab extends PluginSettingTab { const { containerEl } = this; containerEl.empty(); - // Example react component in settings + // Example obsidian settings +const obsidianSettingsEl = containerEl.createDiv(); +new Setting(obsidianSettingsEl) + .setName("Setting #1") + .setDesc("It's a secret") + .addText((text) => + text + .setPlaceholder("Enter your secret") + .setValue(this.plugin.settings.mySetting) + .onChange(async (value) => { + this.plugin.settings.mySetting = value; + await this.plugin.saveSettings(); + }), + ); + + const settingsComponentEl = containerEl.createDiv(); this.root = createRoot(settingsComponentEl); this.root.render( - + , ); - - // Example obsidian settings - const obsidianSettingsEl = containerEl.createDiv(); - new Setting(obsidianSettingsEl) - .setName("Setting #1") - .setDesc("It's a secret") - .addText((text) => - text - .setPlaceholder("Enter your secret") - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - }), - ); } } diff --git a/apps/obsidian/src/components/SettingsTab.tsx b/apps/obsidian/src/components/SettingsTab.tsx new file mode 100644 index 000000000..fcd44adbb --- /dev/null +++ b/apps/obsidian/src/components/SettingsTab.tsx @@ -0,0 +1,56 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import DiscourseGraphPlugin from "../index"; + +export class SettingsTab extends PluginSettingTab { + plugin: DiscourseGraphPlugin; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl("h2", { text: "Discourse Graph Settings" }); + + // Node Types Section + containerEl.createEl("h3", { text: "Node Types" }); + + this.plugin.settings.nodeTypes.forEach((nodeType, index) => { + new Setting(containerEl) + .setName(`Node Type ${index + 1}`) + .addText((text) => + text + .setPlaceholder("Name") + .setValue(nodeType.name) + .onChange(async (value) => { + this.plugin.settings.nodeTypes[index].name = value; + await this.plugin.saveSettings(); + }), + ) + .addText((text) => + text + .setPlaceholder("Format (e.g., [[CLM]] - {content})") + .setValue(nodeType.format) + .onChange(async (value) => { + this.plugin.settings.nodeTypes[index].format = value; + await this.plugin.saveSettings(); + }), + ); + }); + + // Add New Node Type Button + new Setting(containerEl).addButton((btn) => + btn.setButtonText("Add Node Type").onClick(async () => { + this.plugin.settings.nodeTypes.push({ + name: "", + format: "", + }); + await this.plugin.saveSettings(); + this.display(); + }), + ); + } +} diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 021a7d356..4ed49e578 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,20 +1,43 @@ import { Plugin } from "obsidian"; import { registerCommands } from "~/utils/registerCommands"; import { SettingsTab } from "~/components/Settings"; +import { DiscourseNodeType } from "./types"; type Settings = { mySetting: string; + nodeTypes: DiscourseNodeType[]; }; const DEFAULT_SETTINGS: Settings = { mySetting: "default", + nodeTypes: [ + { + name: "Claim", + format: "[[CLM]] - {content}", + shortcut: "C", + color: "#7DA13E", + }, + { + name: "Question", + format: "[[QUE]] - {content}", + shortcut: "Q", + color: "#99890e", + }, + { + name: "Evidence", + format: "[[EVD]] - {content}", + shortcut: "E", + color: "#DB134A", + }, + ], }; export default class DiscourseGraphPlugin extends Plugin { - settings: Settings = { mySetting: "default" }; + settings: Settings = { mySetting: "default", nodeTypes: [] }; async onload() { await this.loadSettings(); + console.log("DiscourseGraphPlugin loaded"); registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); } diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts new file mode 100644 index 000000000..f364edf91 --- /dev/null +++ b/apps/obsidian/src/types.ts @@ -0,0 +1,10 @@ +export type DiscourseNodeType = { + name: string; + format: string; + shortcut?: string; + color?: string; +}; + +export type PluginSettings = { + nodeTypes: DiscourseNodeType[]; +}; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index f2235bf3e..3f5bd36ab 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -22,6 +22,8 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); + console.log(plugin.settings); + // 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", From 4a72b976f798acca4740127f8239c10565863cdc Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 21 Feb 2025 17:57:13 -0800 Subject: [PATCH 04/23] finished current settings --- apps/obsidian/src/components/Settings.tsx | 89 +++++++++++------------ 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 8944eb395..5cb44b4c1 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -1,4 +1,4 @@ -import { StrictMode } from "react"; +import { StrictMode, useState, useEffect } from "react"; import { App, PluginSettingTab, Setting } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; @@ -8,6 +8,7 @@ const NodeTypeSettings = ({ nodeTypes, onNodeTypeChange, onAddNodeType, + onDeleteNodeType, }: { nodeTypes: Array<{ name: string; format: string }>; onNodeTypeChange: ( @@ -16,6 +17,7 @@ const NodeTypeSettings = ({ value: string, ) => Promise; onAddNodeType: () => Promise; + onDeleteNodeType: (index: number) => Promise; }) => { return (
@@ -39,6 +41,12 @@ const NodeTypeSettings = ({ } style={{ flex: 2 }} /> +
))} @@ -51,9 +59,7 @@ const NodeTypeSettings = ({ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { const app = useApp(); - if (!app) { - return
An error occurred
; - } + const [nodeTypes, setNodeTypes] = useState(plugin.settings.nodeTypes || []); // Initialize nodeTypes if undefined if (!plugin.settings.nodeTypes) { @@ -73,46 +79,36 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { }; const handleAddNodeType = async () => { - plugin.settings.nodeTypes.push({ - name: "", - format: "", - }); + const updatedNodeTypes = [ + ...nodeTypes, + { + name: "", + format: "", + }, + ]; + setNodeTypes(updatedNodeTypes); + plugin.settings.nodeTypes = updatedNodeTypes; await plugin.saveSettings(); }; -); - - + // Add delete handler + const handleDeleteNodeType = async (index: number) => { + const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index); + setNodeTypes(updatedNodeTypes); + plugin.settings.nodeTypes = updatedNodeTypes; + await plugin.saveSettings(); + }; return (

Discourse Graph Settings

- {/* Original setting */} - -
-
-
Setting #1
-
It's a secret
-
-
- { - plugin.settings.mySetting = e.target.value; - await plugin.saveSettings(); - }} - /> -
-
-
{/* Node Type Settings */} -

Settings for {app.vault.getName()}

; +

Settings for {app?.vault.getName()}

;
); }; @@ -131,20 +127,19 @@ export class SettingsTab extends PluginSettingTab { containerEl.empty(); // Example obsidian settings -const obsidianSettingsEl = containerEl.createDiv(); -new Setting(obsidianSettingsEl) - .setName("Setting #1") - .setDesc("It's a secret") - .addText((text) => - text - .setPlaceholder("Enter your secret") - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - }), - ); - + const obsidianSettingsEl = containerEl.createDiv(); + new Setting(obsidianSettingsEl) + .setName("Setting #1222") + .setDesc("It's a secret") + .addText((text) => + text + .setPlaceholder("Enter your secret") + .setValue(this.plugin.settings.mySetting) + .onChange(async (value) => { + this.plugin.settings.mySetting = value; + await this.plugin.saveSettings(); + }), + ); const settingsComponentEl = containerEl.createDiv(); this.root = createRoot(settingsComponentEl); From c08d23d9948bbd3ab52fa2af7983590a1ab57827 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 24 Feb 2025 12:10:27 -0800 Subject: [PATCH 05/23] small update --- apps/obsidian/src/components/Settings.tsx | 37 ++++++++++++----------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 5cb44b4c1..184d47b26 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -71,10 +71,14 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { field: "name" | "format", value: string, ) => { - if (!plugin.settings.nodeTypes[index]) { - plugin.settings.nodeTypes[index] = { name: "", format: "" }; + const updatedNodeTypes = [...nodeTypes]; + if (!updatedNodeTypes[index]) { + updatedNodeTypes[index] = { name: "", format: "" }; } - plugin.settings.nodeTypes[index][field] = value; + updatedNodeTypes[index][field] = value; + + setNodeTypes(updatedNodeTypes); + plugin.settings.nodeTypes = updatedNodeTypes; await plugin.saveSettings(); }; @@ -108,7 +112,6 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { onAddNodeType={handleAddNodeType} onDeleteNodeType={handleDeleteNodeType} /> -

Settings for {app?.vault.getName()}

; ); }; @@ -127,19 +130,19 @@ export class SettingsTab extends PluginSettingTab { containerEl.empty(); // Example obsidian settings - const obsidianSettingsEl = containerEl.createDiv(); - new Setting(obsidianSettingsEl) - .setName("Setting #1222") - .setDesc("It's a secret") - .addText((text) => - text - .setPlaceholder("Enter your secret") - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - }), - ); + // const obsidianSettingsEl = containerEl.createDiv(); + // new Setting(obsidianSettingsEl) + // .setName("Setting #1222") + // .setDesc("It's a secret") + // .addText((text) => + // text + // .setPlaceholder("Enter your secret") + // .setValue(this.plugin.settings.mySetting) + // .onChange(async (value) => { + // this.plugin.settings.mySetting = value; + // await this.plugin.saveSettings(); + // }), + // ); const settingsComponentEl = containerEl.createDiv(); this.root = createRoot(settingsComponentEl); From 34f689982ea2b52d8f4a5d0926f4b9eeb23204fb Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 24 Feb 2025 15:12:25 -0800 Subject: [PATCH 06/23] setting for hotkey --- apps/obsidian/src/components/Settings.tsx | 114 +++++++++++++++++++++- apps/obsidian/src/index.ts | 20 ++-- apps/obsidian/src/types.ts | 6 +- 3 files changed, 128 insertions(+), 12 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 184d47b26..2e09c0dd9 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -1,5 +1,5 @@ import { StrictMode, useState, useEffect } from "react"; -import { App, PluginSettingTab, Setting } from "obsidian"; +import { App, Hotkey, Modifier, PluginSettingTab, Setting } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; @@ -57,15 +57,104 @@ const NodeTypeSettings = ({ ); }; +const HotkeyInput = ({ + value, + onChange, +}: { + value: Hotkey; + onChange: (hotkey: Hotkey) => void; +}) => { + console.log("HotkeyInput value:", value); // Debug log + + const [isListening, setIsListening] = useState(false); + const [currentHotkey, setCurrentHotkey] = useState(value); + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + + // Only proceed if at least one modifier is pressed + if (!(e.ctrlKey || e.metaKey || e.altKey || e.shiftKey)) return; + + const modifiers: Modifier[] = []; + if (e.ctrlKey) modifiers.push("Ctrl"); + if (e.metaKey) modifiers.push("Meta"); + if (e.altKey) modifiers.push("Alt"); + if (e.shiftKey) modifiers.push("Shift"); + + const newHotkey: Hotkey = { + modifiers, + key: e.key.toUpperCase(), + }; + + setCurrentHotkey(newHotkey); + }; + + useEffect(() => { + if (isListening) { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + } + }, [isListening]); + + const formatHotkey = (hotkey: Hotkey) => { + if (!hotkey || !hotkey.modifiers) return ""; + + const formattedModifiers = hotkey.modifiers.map((mod) => { + switch (mod) { + case "Mod": + return "Cmd/Ctrl"; + case "Meta": + return "Cmd/Win"; + default: + return mod; + } + }); + + return [...formattedModifiers, hotkey.key].join(" + "); + }; + + return ( +
+ + + {isListening && ( + + )} +
+ ); +}; + const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { const app = useApp(); const [nodeTypes, setNodeTypes] = useState(plugin.settings.nodeTypes || []); - + const [nodeTypeHotkey, setNodeTypeHotkey] = useState( + plugin.settings.nodeTypeHotkey, + ); // Initialize nodeTypes if undefined if (!plugin.settings.nodeTypes) { plugin.settings.nodeTypes = []; } + // Initialize nodeTypeHotkey if undefined + if (!plugin.settings.nodeTypeHotkey) { + plugin.settings.nodeTypeHotkey = { + modifiers: ["Ctrl"], + key: "\\", + }; + } + const handleNodeTypeChange = async ( index: number, field: "name" | "format", @@ -95,17 +184,34 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { await plugin.saveSettings(); }; - // Add delete handler const handleDeleteNodeType = async (index: number) => { const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index); setNodeTypes(updatedNodeTypes); plugin.settings.nodeTypes = updatedNodeTypes; await plugin.saveSettings(); }; + return (

Discourse Graph Settings

- {/* Node Type Settings */} + +
+
+
Node Type Hotkey
+
+ Click Edit and press a modifier + key combination +
+
+ { + setNodeTypeHotkey(newHotkey); + plugin.settings.nodeTypeHotkey = newHotkey; + await plugin.saveSettings(); + }} + /> +
+ Date: Mon, 24 Feb 2025 16:06:23 -0800 Subject: [PATCH 07/23] node instantiation finished --- apps/obsidian/src/components/Settings.tsx | 100 +++++++++++++++----- apps/obsidian/src/utils/registerCommands.ts | 49 +++++++++- 2 files changed, 122 insertions(+), 27 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 2e09c0dd9..992f906a0 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -19,34 +19,68 @@ const NodeTypeSettings = ({ onAddNodeType: () => Promise; onDeleteNodeType: (index: number) => Promise; }) => { + const [formatErrors, setFormatErrors] = useState>({}); + return (

Node Types

{nodeTypes.map((nodeType, index) => (
-
- onNodeTypeChange(index, "name", e.target.value)} - style={{ flex: 1 }} - /> - - onNodeTypeChange(index, "format", e.target.value) - } - style={{ flex: 2 }} - /> - +
+
+ + onNodeTypeChange(index, "name", e.target.value) + } + style={{ flex: 1 }} + /> + { + const value = e.target.value; + const isValid = /^\[([A-Z-]+)\]\s-\s\{content\}$/.test(value); + if (!isValid && value !== "") { + setFormatErrors((prev) => ({ + ...prev, + [index]: + "Format must be [KEYWORD] - {content} with uppercase keyword", + })); + } else { + setFormatErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[index]; + return newErrors; + }); + } + onNodeTypeChange(index, "format", value); + }} + style={{ flex: 2 }} + /> + +
+ {formatErrors[index] && ( +
+ {formatErrors[index]} +
+ )}
))} @@ -155,6 +189,11 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { }; } + const validateFormat = (format: string): boolean => { + const formatRegex = /^\[([A-Z-]+)\]\s-\s\{content\}$/; + return formatRegex.test(format); + }; + const handleNodeTypeChange = async ( index: number, field: "name" | "format", @@ -164,11 +203,20 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { if (!updatedNodeTypes[index]) { updatedNodeTypes[index] = { name: "", format: "" }; } - updatedNodeTypes[index][field] = value; + updatedNodeTypes[index][field] = value; setNodeTypes(updatedNodeTypes); - plugin.settings.nodeTypes = updatedNodeTypes; - await plugin.saveSettings(); + + // Only save if format is valid or empty + if (field === "format") { + if (value === "" || validateFormat(value)) { + plugin.settings.nodeTypes = updatedNodeTypes; + await plugin.saveSettings(); + } + } else { + plugin.settings.nodeTypes = updatedNodeTypes; + await plugin.saveSettings(); + } }; const handleAddNodeType = async () => { diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 3f5bd36ab..ca4faf41e 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -1,6 +1,39 @@ -import { Editor, MarkdownFileInfo, MarkdownView } from "obsidian"; +import { + Editor, + MarkdownFileInfo, + MarkdownView, + App, + Notice, + SuggestModal, +} from "obsidian"; import { SampleModal } from "~/components/SampleModal"; import type DiscourseGraphPlugin from "~/index"; +import { DiscourseNodeType } from "~/types"; + +class NodeTypeModal extends SuggestModal<{ name: string; format: string }> { + constructor( + app: App, + private editor: Editor, + private nodeTypes: DiscourseNodeType[], + ) { + super(app); + } + + getSuggestions(): DiscourseNodeType[] { + return this.nodeTypes; + } + + renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { + el.createEl("div", { text: nodeType.name }); + } + + onChooseSuggestion(nodeType: DiscourseNodeType) { + const selectedText = this.editor.getSelection(); + const heading = nodeType.format.split(" ")[0]; + const nodeFormat = `[[${heading} - ${selectedText}]]`; + this.editor.replaceSelection(nodeFormat); + } +} export const registerCommands = (plugin: DiscourseGraphPlugin) => { // This adds a simple command that can be triggered anywhere @@ -44,4 +77,18 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { } }, }); + + plugin.addCommand({ + id: "open-node-type-menu", + name: "Open Node Type Menu", + hotkeys: [plugin.settings.nodeTypeHotkey], + editorCallback: (editor: Editor) => { + if (!plugin.settings.nodeTypes.length) { + new Notice("No node types configured!"); + return; + } + + new NodeTypeModal(plugin.app, editor, plugin.settings.nodeTypes).open(); + }, + }); }; From 833e2460df0a360ee0eb229807d3280b329c5d2a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 25 Feb 2025 15:20:55 -0800 Subject: [PATCH 08/23] address PR comments --- apps/obsidian/src/components/Settings.tsx | 107 +++++++++---------- apps/obsidian/src/components/SettingsTab.tsx | 3 - apps/obsidian/src/index.ts | 10 +- apps/obsidian/src/utils/registerCommands.ts | 2 +- 4 files changed, 55 insertions(+), 67 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 992f906a0..54f0f1172 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -9,6 +9,7 @@ const NodeTypeSettings = ({ onNodeTypeChange, onAddNodeType, onDeleteNodeType, + formatErrors, }: { nodeTypes: Array<{ name: string; format: string }>; onNodeTypeChange: ( @@ -18,9 +19,8 @@ const NodeTypeSettings = ({ ) => Promise; onAddNodeType: () => Promise; onDeleteNodeType: (index: number) => Promise; + formatErrors: Record; }) => { - const [formatErrors, setFormatErrors] = useState>({}); - return (

Node Types

@@ -41,26 +41,11 @@ const NodeTypeSettings = ({ /> { - const value = e.target.value; - const isValid = /^\[([A-Z-]+)\]\s-\s\{content\}$/.test(value); - if (!isValid && value !== "") { - setFormatErrors((prev) => ({ - ...prev, - [index]: - "Format must be [KEYWORD] - {content} with uppercase keyword", - })); - } else { - setFormatErrors((prev) => { - const newErrors = { ...prev }; - delete newErrors[index]; - return newErrors; - }); - } - onNodeTypeChange(index, "format", value); - }} + onChange={(e) => + onNodeTypeChange(index, "format", e.target.value) + } style={{ flex: 2 }} />
); @@ -282,22 +297,6 @@ export class SettingsTab extends PluginSettingTab { display(): void { const { containerEl } = this; containerEl.empty(); - - // Example obsidian settings - // const obsidianSettingsEl = containerEl.createDiv(); - // new Setting(obsidianSettingsEl) - // .setName("Setting #1222") - // .setDesc("It's a secret") - // .addText((text) => - // text - // .setPlaceholder("Enter your secret") - // .setValue(this.plugin.settings.mySetting) - // .onChange(async (value) => { - // this.plugin.settings.mySetting = value; - // await this.plugin.saveSettings(); - // }), - // ); - const settingsComponentEl = containerEl.createDiv(); this.root = createRoot(settingsComponentEl); this.root.render( diff --git a/apps/obsidian/src/components/SettingsTab.tsx b/apps/obsidian/src/components/SettingsTab.tsx index fcd44adbb..945238b84 100644 --- a/apps/obsidian/src/components/SettingsTab.tsx +++ b/apps/obsidian/src/components/SettingsTab.tsx @@ -14,8 +14,6 @@ export class SettingsTab extends PluginSettingTab { containerEl.empty(); containerEl.createEl("h2", { text: "Discourse Graph Settings" }); - - // Node Types Section containerEl.createEl("h3", { text: "Node Types" }); this.plugin.settings.nodeTypes.forEach((nodeType, index) => { @@ -41,7 +39,6 @@ export class SettingsTab extends PluginSettingTab { ); }); - // Add New Node Type Button new Setting(containerEl).addButton((btn) => btn.setButtonText("Add Node Type").onClick(async () => { this.plugin.settings.nodeTypes.push({ diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index e2492837d..867bfe43e 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -32,18 +32,10 @@ const DEFAULT_SETTINGS: Settings = { }; export default class DiscourseGraphPlugin extends Plugin { - settings: Settings = { - mySetting: "default", - nodeTypes: [], - nodeTypeHotkey: { - modifiers: ["Mod", "Shift"], - key: "Backslash", - }, - }; + settings: Settings = { ...DEFAULT_SETTINGS }; async onload() { await this.loadSettings(); - console.log("DiscourseGraphPlugin loaded"); registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); } diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index ca4faf41e..93010ad6e 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -81,7 +81,7 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { plugin.addCommand({ id: "open-node-type-menu", name: "Open Node Type Menu", - hotkeys: [plugin.settings.nodeTypeHotkey], + hotkeys: [plugin.settings.nodeTypeHotkey].filter(Boolean), editorCallback: (editor: Editor) => { if (!plugin.settings.nodeTypes.length) { new Notice("No node types configured!"); From 2ee0d281cfe84f4e73d444ccc92544eb6445477e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 25 Feb 2025 15:23:45 -0800 Subject: [PATCH 09/23] add description --- apps/obsidian/src/components/Settings.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 54f0f1172..a205b48a6 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -166,7 +166,6 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { ); const [formatErrors, setFormatErrors] = useState>({}); - // Initialize settings if needed useEffect(() => { const initializeSettings = async () => { let needsSave = false; @@ -261,7 +260,11 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => {
Node Type Hotkey
- Click Edit and press a modifier + key combination +

+ This hotkey will open the node type menu to instantly create a new + node. +

+

Click Edit and press a modifier + key combination

Date: Thu, 27 Feb 2025 16:10:34 -0800 Subject: [PATCH 10/23] address PR comments --- apps/obsidian/src/components/Settings.tsx | 53 ++++++++---- apps/obsidian/src/components/SettingsTab.tsx | 53 ------------ apps/obsidian/src/index.ts | 83 +++++++++++++------ .../utils/getDiscourseNodeFormatExpression.ts | 9 ++ apps/obsidian/src/utils/registerCommands.ts | 39 --------- 5 files changed, 101 insertions(+), 136 deletions(-) delete mode 100644 apps/obsidian/src/components/SettingsTab.tsx create mode 100644 apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index a205b48a6..c5c2ceac1 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -3,6 +3,7 @@ import { App, Hotkey, Modifier, PluginSettingTab, Setting } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; +import { getDiscourseNodeFormatExpression } from "../utils/getDiscourseNodeFormatExpression"; const NodeTypeSettings = ({ nodeTypes, @@ -81,7 +82,7 @@ const HotkeyInput = ({ onChange, }: { value: Hotkey; - onChange: (hotkey: Hotkey) => void; + onChange: (hotkey: Hotkey) => Promise; }) => { const [isListening, setIsListening] = useState(false); const [currentHotkey, setCurrentHotkey] = useState(value); @@ -129,6 +130,15 @@ const HotkeyInput = ({ return [...formattedModifiers, hotkey.key].join(" + "); }; + const handleSave = async () => { + try { + await onChange(currentHotkey); + setIsListening(false); + } catch (error) { + console.error("Failed to save hotkey:", error); + } + }; + return (
@@ -138,16 +148,7 @@ const HotkeyInput = ({ > {isListening ? "Listening..." : "Edit"} - {isListening && ( - - )} + {isListening && }
); }; @@ -192,8 +193,15 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { }, [plugin]); const validateFormat = (format: string): boolean => { - const formatRegex = /^\[([A-Z-]+)\]\s-\s\{content\}$/; - return formatRegex.test(format); + // TODO: fix validation format + if (!format) return true; // Empty format is valid + try { + const regex = getDiscourseNodeFormatExpression(format); + // Test with a sample string to make sure it's a valid format + return regex.test("[TEST] - Sample content"); + } catch (e) { + return false; + } }; const handleNodeTypeChange = async ( @@ -215,7 +223,7 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { setFormatErrors((prev) => ({ ...prev, [index]: - "Format must be [KEYWORD] - {content} with uppercase keyword", + "Invalid format. You can use any {variable} in your format, e.g., [TYPE] - {content}", })); } else { setFormatErrors((prev) => { @@ -252,6 +260,17 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { await plugin.saveSettings(); }; + const handleHotkeyChange = async (newHotkey: Hotkey) => { + try { + plugin.settings.nodeTypeHotkey = newHotkey; + await plugin.saveSettings(); + setNodeTypeHotkey(newHotkey); + setNodeTypeHotkey(plugin.settings.nodeTypeHotkey); + } catch (error) { + console.error("Failed to save hotkey:", error); + } + }; + return (

Discourse Graph Settings

@@ -270,9 +289,7 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { { - setNodeTypeHotkey(newHotkey); - plugin.settings.nodeTypeHotkey = newHotkey; - await plugin.saveSettings(); + await handleHotkeyChange(newHotkey); }} />
@@ -310,4 +327,4 @@ export class SettingsTab extends PluginSettingTab { , ); } -} +} \ No newline at end of file diff --git a/apps/obsidian/src/components/SettingsTab.tsx b/apps/obsidian/src/components/SettingsTab.tsx deleted file mode 100644 index 945238b84..000000000 --- a/apps/obsidian/src/components/SettingsTab.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { App, PluginSettingTab, Setting } from "obsidian"; -import DiscourseGraphPlugin from "../index"; - -export class SettingsTab extends PluginSettingTab { - plugin: DiscourseGraphPlugin; - - constructor(app: App, plugin: DiscourseGraphPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const { containerEl } = this; - containerEl.empty(); - - containerEl.createEl("h2", { text: "Discourse Graph Settings" }); - containerEl.createEl("h3", { text: "Node Types" }); - - this.plugin.settings.nodeTypes.forEach((nodeType, index) => { - new Setting(containerEl) - .setName(`Node Type ${index + 1}`) - .addText((text) => - text - .setPlaceholder("Name") - .setValue(nodeType.name) - .onChange(async (value) => { - this.plugin.settings.nodeTypes[index].name = value; - await this.plugin.saveSettings(); - }), - ) - .addText((text) => - text - .setPlaceholder("Format (e.g., [[CLM]] - {content})") - .setValue(nodeType.format) - .onChange(async (value) => { - this.plugin.settings.nodeTypes[index].format = value; - await this.plugin.saveSettings(); - }), - ); - }); - - new Setting(containerEl).addButton((btn) => - btn.setButtonText("Add Node Type").onClick(async () => { - this.plugin.settings.nodeTypes.push({ - name: "", - format: "", - }); - await this.plugin.saveSettings(); - this.display(); - }), - ); - } -} diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 867bfe43e..468c3fa7b 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,42 +1,72 @@ -import { Plugin } from "obsidian"; -import { registerCommands } from "~/utils/registerCommands"; +import { App, Editor, Notice, Plugin, SuggestModal } from "obsidian"; import { SettingsTab } from "~/components/Settings"; -import { Settings } from "./types"; +import { DiscourseNodeType, Settings } from "./types"; const DEFAULT_SETTINGS: Settings = { mySetting: "default", - nodeTypes: [ - { - name: "Claim", - format: "[[CLM]] - {content}", - shortcut: "C", - color: "#7DA13E", - }, - { - name: "Question", - format: "[[QUE]] - {content}", - shortcut: "Q", - color: "#99890e", - }, - { - name: "Evidence", - format: "[[EVD]] - {content}", - shortcut: "E", - color: "#DB134A", - }, - ], + nodeTypes: [], nodeTypeHotkey: { - modifiers: ["Mod", "Shift"], - key: "Backslash", + modifiers: [], + key: "", }, }; +class NodeTypeModal extends SuggestModal { + constructor( + app: App, + private editor: Editor, + private nodeTypes: DiscourseNodeType[], + ) { + super(app); + } + + getItemText(item: DiscourseNodeType): string { + return item.name; + } + + // Get all available items + getSuggestions() { + const query = this.inputEl.value.toLowerCase(); + return this.nodeTypes.filter((node) => + this.getItemText(node).toLowerCase().includes(query), + ); + } + + renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { + el.createEl("div", { text: nodeType.name }); + } + + onChooseSuggestion(nodeType: DiscourseNodeType) { + const selectedText = this.editor.getSelection(); + // TODO: get the regex from the nodeType + const heading = nodeType.format.split(" ")[0]; + const nodeFormat = `[[${heading} - ${selectedText}]]`; + this.editor.replaceSelection(nodeFormat); + } +} + export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; + private registerNodeTypeCommand() { + return this.addCommand({ + id: "open-node-type-menu", + name: "Open Node Type Menu", + hotkeys: [this.settings.nodeTypeHotkey], + editorCallback: (editor: Editor) => { + if (!this.settings.nodeTypes.length) { + new Notice("No node types configured!"); + return; + } + + new NodeTypeModal(this.app, editor, this.settings.nodeTypes).open(); + }, + }); + } + async onload() { await this.loadSettings(); - registerCommands(this); + this.registerNodeTypeCommand(); this.addSettingTab(new SettingsTab(this.app, this)); } @@ -48,5 +78,6 @@ export default class DiscourseGraphPlugin extends Plugin { async saveSettings() { await this.saveData(this.settings); + await this.registerNodeTypeCommand(); } } diff --git a/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts b/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts new file mode 100644 index 000000000..ea2e96bdc --- /dev/null +++ b/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts @@ -0,0 +1,9 @@ +export const getDiscourseNodeFormatExpression = (format: string) => + format + ? new RegExp( + `^${format + .replace(/(\[|\]|\?|\.|\+)/g, "\\$1") + .replace(/{[a-zA-Z]+}/g, "(.*?)")}$`, + "s", + ) + : /$^/; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 93010ad6e..171ba6bbe 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -10,31 +10,6 @@ import { SampleModal } from "~/components/SampleModal"; import type DiscourseGraphPlugin from "~/index"; import { DiscourseNodeType } from "~/types"; -class NodeTypeModal extends SuggestModal<{ name: string; format: string }> { - constructor( - app: App, - private editor: Editor, - private nodeTypes: DiscourseNodeType[], - ) { - super(app); - } - - getSuggestions(): DiscourseNodeType[] { - return this.nodeTypes; - } - - renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { - el.createEl("div", { text: nodeType.name }); - } - - onChooseSuggestion(nodeType: DiscourseNodeType) { - const selectedText = this.editor.getSelection(); - const heading = nodeType.format.split(" ")[0]; - const nodeFormat = `[[${heading} - ${selectedText}]]`; - this.editor.replaceSelection(nodeFormat); - } -} - export const registerCommands = (plugin: DiscourseGraphPlugin) => { // This adds a simple command that can be triggered anywhere plugin.addCommand({ @@ -77,18 +52,4 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { } }, }); - - plugin.addCommand({ - id: "open-node-type-menu", - name: "Open Node Type Menu", - hotkeys: [plugin.settings.nodeTypeHotkey].filter(Boolean), - editorCallback: (editor: Editor) => { - if (!plugin.settings.nodeTypes.length) { - new Notice("No node types configured!"); - return; - } - - new NodeTypeModal(plugin.app, editor, plugin.settings.nodeTypes).open(); - }, - }); }; From 5eedbc4c25ddc1c69350050b1c12cfc489b008c6 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 27 Feb 2025 16:48:53 -0800 Subject: [PATCH 11/23] fix the NodeType validation --- apps/obsidian/src/components/Settings.tsx | 19 ++---------- apps/obsidian/src/index.ts | 17 ++++++++--- apps/obsidian/src/utils/validateNodeFormat.ts | 29 +++++++++++++++++++ 3 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 apps/obsidian/src/utils/validateNodeFormat.ts diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index c5c2ceac1..1fc4b7203 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -3,7 +3,7 @@ import { App, Hotkey, Modifier, PluginSettingTab, Setting } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; -import { getDiscourseNodeFormatExpression } from "../utils/getDiscourseNodeFormatExpression"; +import { validateNodeFormat } from "../utils/validateNodeFormat"; const NodeTypeSettings = ({ nodeTypes, @@ -192,18 +192,6 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { initializeSettings(); }, [plugin]); - const validateFormat = (format: string): boolean => { - // TODO: fix validation format - if (!format) return true; // Empty format is valid - try { - const regex = getDiscourseNodeFormatExpression(format); - // Test with a sample string to make sure it's a valid format - return regex.test("[TEST] - Sample content"); - } catch (e) { - return false; - } - }; - const handleNodeTypeChange = async ( index: number, field: "name" | "format", @@ -218,12 +206,11 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { setNodeTypes(updatedNodeTypes); if (field === "format") { - const isValid = value === "" || validateFormat(value); + const { isValid, error } = validateNodeFormat(value); if (!isValid) { setFormatErrors((prev) => ({ ...prev, - [index]: - "Invalid format. You can use any {variable} in your format, e.g., [TYPE] - {content}", + [index]: error ?? "Invalid format", })); } else { setFormatErrors((prev) => { diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 468c3fa7b..d66d3feef 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,6 +1,7 @@ import { App, Editor, Notice, Plugin, SuggestModal } from "obsidian"; import { SettingsTab } from "~/components/Settings"; import { DiscourseNodeType, Settings } from "./types"; +import { getDiscourseNodeFormatExpression } from "./utils/getDiscourseNodeFormatExpression"; const DEFAULT_SETTINGS: Settings = { mySetting: "default", @@ -38,10 +39,18 @@ class NodeTypeModal extends SuggestModal { onChooseSuggestion(nodeType: DiscourseNodeType) { const selectedText = this.editor.getSelection(); - // TODO: get the regex from the nodeType - const heading = nodeType.format.split(" ")[0]; - const nodeFormat = `[[${heading} - ${selectedText}]]`; - this.editor.replaceSelection(nodeFormat); + const regex = getDiscourseNodeFormatExpression(nodeType.format); + + const nodeFormat = regex.source.match(/^\^(.*?)\(\.\*\?\)(.*?)\$$/); + if (!nodeFormat) return; + + const formattedNodeName = + nodeFormat[1]?.replace(/\\/g, "") + + selectedText + + nodeFormat[2]?.replace(/\\/g, ""); + if (!nodeFormat) return; + + this.editor.replaceSelection(`[[${formattedNodeName}]]`); } } diff --git a/apps/obsidian/src/utils/validateNodeFormat.ts b/apps/obsidian/src/utils/validateNodeFormat.ts new file mode 100644 index 000000000..a9198b2f0 --- /dev/null +++ b/apps/obsidian/src/utils/validateNodeFormat.ts @@ -0,0 +1,29 @@ +export function validateNodeFormat(format: string): { + 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 ]], these will be added automatically", + }; + } + + const hasVariable = /{[a-zA-Z]+}/.test(format); + if (!hasVariable) { + return { + isValid: false, + error: "Format must contain at least one variable in {varName} format", + }; + } + + return { isValid: true }; +} From 435233d6759f51b2ef511bed872b5b05dfa850b8 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 3 Mar 2025 17:11:04 -0500 Subject: [PATCH 12/23] address PR review --- .../obsidian/src/components/NodeTypeModal.tsx | 45 +++ apps/obsidian/src/components/Settings.tsx | 272 +++++------------- apps/obsidian/src/index.ts | 72 +---- apps/obsidian/src/types.ts | 4 - apps/obsidian/src/utils/registerCommands.ts | 11 +- 5 files changed, 125 insertions(+), 279 deletions(-) create mode 100644 apps/obsidian/src/components/NodeTypeModal.tsx diff --git a/apps/obsidian/src/components/NodeTypeModal.tsx b/apps/obsidian/src/components/NodeTypeModal.tsx new file mode 100644 index 000000000..5d20752fd --- /dev/null +++ b/apps/obsidian/src/components/NodeTypeModal.tsx @@ -0,0 +1,45 @@ +import { App, Editor, SuggestModal } from "obsidian"; +import { DiscourseNodeType } from "../types"; +import { getDiscourseNodeFormatExpression } from "../utils/getDiscourseNodeFormatExpression"; + +export class NodeTypeModal extends SuggestModal { + constructor( + app: App, + private editor: Editor, + private nodeTypes: DiscourseNodeType[], + ) { + super(app); + } + + getItemText(item: DiscourseNodeType): string { + return item.name; + } + + // Get all available items + getSuggestions() { + const query = this.inputEl.value.toLowerCase(); + return this.nodeTypes.filter((node) => + this.getItemText(node).toLowerCase().includes(query), + ); + } + + renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { + el.createEl("div", { text: nodeType.name }); + } + + onChooseSuggestion(nodeType: DiscourseNodeType) { + const selectedText = this.editor.getSelection(); + const regex = getDiscourseNodeFormatExpression(nodeType.format); + + const nodeFormat = regex.source.match(/^\^(.*?)\(\.\*\?\)(.*?)\$$/); + if (!nodeFormat) return; + + const formattedNodeName = + nodeFormat[1]?.replace(/\\/g, "") + + selectedText + + nodeFormat[2]?.replace(/\\/g, ""); + if (!nodeFormat) return; + + this.editor.replaceSelection(`[[${formattedNodeName}]]`); + } +} diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 1fc4b7203..dbf2a2753 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -1,170 +1,14 @@ import { StrictMode, useState, useEffect } from "react"; -import { App, Hotkey, Modifier, PluginSettingTab, Setting } from "obsidian"; +import { App, PluginSettingTab } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; import { validateNodeFormat } from "../utils/validateNodeFormat"; -const NodeTypeSettings = ({ - nodeTypes, - onNodeTypeChange, - onAddNodeType, - onDeleteNodeType, - formatErrors, -}: { - nodeTypes: Array<{ name: string; format: string }>; - onNodeTypeChange: ( - index: number, - field: "name" | "format", - value: string, - ) => Promise; - onAddNodeType: () => Promise; - onDeleteNodeType: (index: number) => Promise; - formatErrors: Record; -}) => { - return ( -
-

Node Types

- {nodeTypes.map((nodeType, index) => ( -
-
-
- - onNodeTypeChange(index, "name", e.target.value) - } - style={{ flex: 1 }} - /> - - onNodeTypeChange(index, "format", e.target.value) - } - style={{ flex: 2 }} - /> - -
- {formatErrors[index] && ( -
- {formatErrors[index]} -
- )} -
-
- ))} -
- -
-
- ); -}; - -const HotkeyInput = ({ - value, - onChange, -}: { - value: Hotkey; - onChange: (hotkey: Hotkey) => Promise; -}) => { - const [isListening, setIsListening] = useState(false); - const [currentHotkey, setCurrentHotkey] = useState(value); - - const handleKeyDown = (e: KeyboardEvent) => { - e.preventDefault(); - - if (!(e.ctrlKey || e.metaKey || e.altKey || e.shiftKey)) return; - - const modifiers: Modifier[] = []; - if (e.ctrlKey) modifiers.push("Ctrl"); - if (e.metaKey) modifiers.push("Meta"); - if (e.altKey) modifiers.push("Alt"); - if (e.shiftKey) modifiers.push("Shift"); - - const newHotkey: Hotkey = { - modifiers, - key: e.key.toUpperCase(), - }; - - setCurrentHotkey(newHotkey); - }; - - useEffect(() => { - if (isListening) { - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - } - }, [isListening]); - - const formatHotkey = (hotkey: Hotkey) => { - if (!hotkey || !hotkey.modifiers) return ""; - - const formattedModifiers = hotkey.modifiers.map((mod) => { - switch (mod) { - case "Mod": - return "Cmd/Ctrl"; - case "Meta": - return "Cmd/Win"; - default: - return mod; - } - }); - - return [...formattedModifiers, hotkey.key].join(" + "); - }; - - const handleSave = async () => { - try { - await onChange(currentHotkey); - setIsListening(false); - } catch (error) { - console.error("Failed to save hotkey:", error); - } - }; - - return ( -
- - - {isListening && } -
- ); -}; - -const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { - const app = useApp(); +const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { const [nodeTypes, setNodeTypes] = useState( () => plugin.settings.nodeTypes ?? [], ); - const [nodeTypeHotkey, setNodeTypeHotkey] = useState( - () => - plugin.settings.nodeTypeHotkey ?? { - modifiers: ["Ctrl"], - key: "\\", - }, - ); const [formatErrors, setFormatErrors] = useState>({}); useEffect(() => { @@ -176,14 +20,6 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { needsSave = true; } - if (!plugin.settings.nodeTypeHotkey) { - plugin.settings.nodeTypeHotkey = { - modifiers: ["Ctrl"], - key: "\\", - }; - needsSave = true; - } - if (needsSave) { await plugin.saveSettings(); } @@ -196,7 +32,7 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { index: number, field: "name" | "format", value: string, - ) => { + ): Promise => { const updatedNodeTypes = [...nodeTypes]; if (!updatedNodeTypes[index]) { updatedNodeTypes[index] = { name: "", format: "" }; @@ -227,7 +63,7 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { } }; - const handleAddNodeType = async () => { + const handleAddNodeType = async (): Promise => { const updatedNodeTypes = [ ...nodeTypes, { @@ -240,54 +76,71 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { await plugin.saveSettings(); }; - const handleDeleteNodeType = async (index: number) => { + const handleDeleteNodeType = async (index: number): Promise => { const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index); setNodeTypes(updatedNodeTypes); plugin.settings.nodeTypes = updatedNodeTypes; await plugin.saveSettings(); }; - - const handleHotkeyChange = async (newHotkey: Hotkey) => { - try { - plugin.settings.nodeTypeHotkey = newHotkey; - await plugin.saveSettings(); - setNodeTypeHotkey(newHotkey); - setNodeTypeHotkey(plugin.settings.nodeTypeHotkey); - } catch (error) { - console.error("Failed to save hotkey:", error); - } - }; - return ( -
-

Discourse Graph Settings

- -
-
-
Node Type Hotkey
-
-

- This hotkey will open the node type menu to instantly create a new - node. -

-

Click Edit and press a modifier + key combination

+
+

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]} +
+ )}
- { - await handleHotkeyChange(newHotkey); - }} - /> + ))} +
+
+
+ ); +}; - +const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { + return ( +
+
); }; @@ -314,4 +167,11 @@ export class SettingsTab extends PluginSettingTab { , ); } + + hide(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } } \ No newline at end of file diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index d66d3feef..ca0598fbd 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,81 +1,18 @@ -import { App, Editor, Notice, Plugin, SuggestModal } from "obsidian"; +import { Plugin } from "obsidian"; import { SettingsTab } from "~/components/Settings"; -import { DiscourseNodeType, Settings } from "./types"; -import { getDiscourseNodeFormatExpression } from "./utils/getDiscourseNodeFormatExpression"; +import { Settings } from "./types"; +import { registerCommands } from "./utils/registerCommands"; const DEFAULT_SETTINGS: Settings = { - mySetting: "default", nodeTypes: [], - nodeTypeHotkey: { - modifiers: [], - key: "", - }, }; -class NodeTypeModal extends SuggestModal { - constructor( - app: App, - private editor: Editor, - private nodeTypes: DiscourseNodeType[], - ) { - super(app); - } - - getItemText(item: DiscourseNodeType): string { - return item.name; - } - - // Get all available items - getSuggestions() { - const query = this.inputEl.value.toLowerCase(); - return this.nodeTypes.filter((node) => - this.getItemText(node).toLowerCase().includes(query), - ); - } - - renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { - el.createEl("div", { text: nodeType.name }); - } - - onChooseSuggestion(nodeType: DiscourseNodeType) { - const selectedText = this.editor.getSelection(); - const regex = getDiscourseNodeFormatExpression(nodeType.format); - - const nodeFormat = regex.source.match(/^\^(.*?)\(\.\*\?\)(.*?)\$$/); - if (!nodeFormat) return; - - const formattedNodeName = - nodeFormat[1]?.replace(/\\/g, "") + - selectedText + - nodeFormat[2]?.replace(/\\/g, ""); - if (!nodeFormat) return; - - this.editor.replaceSelection(`[[${formattedNodeName}]]`); - } -} - export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; - private registerNodeTypeCommand() { - return this.addCommand({ - id: "open-node-type-menu", - name: "Open Node Type Menu", - hotkeys: [this.settings.nodeTypeHotkey], - editorCallback: (editor: Editor) => { - if (!this.settings.nodeTypes.length) { - new Notice("No node types configured!"); - return; - } - - new NodeTypeModal(this.app, editor, this.settings.nodeTypes).open(); - }, - }); - } - async onload() { await this.loadSettings(); - this.registerNodeTypeCommand(); + registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); } @@ -87,6 +24,5 @@ export default class DiscourseGraphPlugin extends Plugin { async saveSettings() { await this.saveData(this.settings); - await this.registerNodeTypeCommand(); } } diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index d98c8efb2..208afc132 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -1,5 +1,3 @@ -import { Hotkey } from "obsidian"; - export type DiscourseNodeType = { name: string; format: string; @@ -8,7 +6,5 @@ export type DiscourseNodeType = { }; export type Settings = { - mySetting: string; nodeTypes: DiscourseNodeType[]; - nodeTypeHotkey: Hotkey; }; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 171ba6bbe..b4adb7d7c 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -8,7 +8,7 @@ import { } from "obsidian"; import { SampleModal } from "~/components/SampleModal"; import type DiscourseGraphPlugin from "~/index"; -import { DiscourseNodeType } from "~/types"; +import { NodeTypeModal } from "~/components/NodeTypeModal"; export const registerCommands = (plugin: DiscourseGraphPlugin) => { // This adds a simple command that can be triggered anywhere @@ -52,4 +52,13 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { } }, }); + + plugin.addCommand({ + id: "open-node-type-menu", + name: "Open Node Type Menu", + hotkeys: [{ modifiers: ["Mod"], key: "\\" }], + editorCallback: (editor: Editor) => { + new NodeTypeModal(plugin.app, editor, plugin.settings.nodeTypes).open(); + }, + }); }; From d9dc4337977f7d2ad5fa5217898162045c0504b5 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 3 Mar 2025 17:37:55 -0500 Subject: [PATCH 13/23] add Save button for new changes --- .../obsidian/src/components/NodeTypeModal.tsx | 1 - apps/obsidian/src/components/Settings.tsx | 53 +++++++++++++++---- apps/obsidian/src/utils/registerCommands.ts | 1 - apps/obsidian/src/utils/validateNodeFormat.ts | 3 +- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/apps/obsidian/src/components/NodeTypeModal.tsx b/apps/obsidian/src/components/NodeTypeModal.tsx index 5d20752fd..495f2aac7 100644 --- a/apps/obsidian/src/components/NodeTypeModal.tsx +++ b/apps/obsidian/src/components/NodeTypeModal.tsx @@ -15,7 +15,6 @@ export class NodeTypeModal extends SuggestModal { return item.name; } - // Get all available items getSuggestions() { const query = this.inputEl.value.toLowerCase(); return this.nodeTypes.filter((node) => diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index dbf2a2753..89d3af77a 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -10,6 +10,7 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { () => plugin.settings.nodeTypes ?? [], ); const [formatErrors, setFormatErrors] = useState>({}); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); useEffect(() => { const initializeSettings = async () => { @@ -40,6 +41,7 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { updatedNodeTypes[index][field] = value; setNodeTypes(updatedNodeTypes); + setHasUnsavedChanges(true); if (field === "format") { const { isValid, error } = validateNodeFormat(value); @@ -54,16 +56,11 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { delete newErrors[index]; return newErrors; }); - plugin.settings.nodeTypes = updatedNodeTypes; - await plugin.saveSettings(); } - } else { - plugin.settings.nodeTypes = updatedNodeTypes; - await plugin.saveSettings(); } }; - const handleAddNodeType = async (): Promise => { + const handleAddNodeType = (): void => { const updatedNodeTypes = [ ...nodeTypes, { @@ -72,8 +69,7 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { }, ]; setNodeTypes(updatedNodeTypes); - plugin.settings.nodeTypes = updatedNodeTypes; - await plugin.saveSettings(); + setHasUnsavedChanges(true); }; const handleDeleteNodeType = async (index: number): Promise => { @@ -82,6 +78,29 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { 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); + }; + return (

Node Types

@@ -131,8 +150,24 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => {
))}
- +
+ + +
+ {hasUnsavedChanges && ( +
+ You have unsaved changes +
+ )}
); }; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index b4adb7d7c..5aa566dee 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -30,7 +30,6 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); - console.log(plugin.settings); // This adds a complex command that can check whether the current state of the app allows execution of the command plugin.addCommand({ diff --git a/apps/obsidian/src/utils/validateNodeFormat.ts b/apps/obsidian/src/utils/validateNodeFormat.ts index a9198b2f0..da73e4afd 100644 --- a/apps/obsidian/src/utils/validateNodeFormat.ts +++ b/apps/obsidian/src/utils/validateNodeFormat.ts @@ -12,8 +12,7 @@ export function validateNodeFormat(format: string): { if (format.includes("[[") || format.includes("]]")) { return { isValid: false, - error: - "Format should not contain double brackets [[ or ]], these will be added automatically", + error: "Format should not contain double brackets [[ or ]]", }; } From de5242ea7d4854fe065337b38b9e738e931ceefd Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 4 Mar 2025 13:36:22 -0500 Subject: [PATCH 14/23] types defined and basic settings up --- .../obsidian/src/components/NodeTypeModal.tsx | 12 +- .../src/components/RelationshipSettings.tsx | 285 ++++++++++++++++++ .../components/RelationshipTypeSettings.tsx | 172 +++++++++++ apps/obsidian/src/components/Settings.tsx | 82 ++++- apps/obsidian/src/index.ts | 2 + apps/obsidian/src/types.ts | 18 +- 6 files changed, 561 insertions(+), 10 deletions(-) create mode 100644 apps/obsidian/src/components/RelationshipSettings.tsx create mode 100644 apps/obsidian/src/components/RelationshipTypeSettings.tsx diff --git a/apps/obsidian/src/components/NodeTypeModal.tsx b/apps/obsidian/src/components/NodeTypeModal.tsx index 495f2aac7..baa9fdfc8 100644 --- a/apps/obsidian/src/components/NodeTypeModal.tsx +++ b/apps/obsidian/src/components/NodeTypeModal.tsx @@ -1,17 +1,17 @@ import { App, Editor, SuggestModal } from "obsidian"; -import { DiscourseNodeType } from "../types"; +import { DiscourseNode } from "../types"; import { getDiscourseNodeFormatExpression } from "../utils/getDiscourseNodeFormatExpression"; -export class NodeTypeModal extends SuggestModal { +export class NodeTypeModal extends SuggestModal { constructor( app: App, private editor: Editor, - private nodeTypes: DiscourseNodeType[], + private nodeTypes: DiscourseNode[], ) { super(app); } - getItemText(item: DiscourseNodeType): string { + getItemText(item: DiscourseNode): string { return item.name; } @@ -22,11 +22,11 @@ export class NodeTypeModal extends SuggestModal { ); } - renderSuggestion(nodeType: DiscourseNodeType, el: HTMLElement) { + renderSuggestion(nodeType: DiscourseNode, el: HTMLElement) { el.createEl("div", { text: nodeType.name }); } - onChooseSuggestion(nodeType: DiscourseNodeType) { + onChooseSuggestion(nodeType: DiscourseNode) { const selectedText = this.editor.getSelection(); const regex = getDiscourseNodeFormatExpression(nodeType.format); diff --git a/apps/obsidian/src/components/RelationshipSettings.tsx b/apps/obsidian/src/components/RelationshipSettings.tsx new file mode 100644 index 000000000..a9866731c --- /dev/null +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -0,0 +1,285 @@ +import { useState, useEffect } from "react"; +import type DiscourseGraphPlugin from "../index"; +import { + DiscourseRelation, + DiscourseNode, + DiscourseRelationType, +} from "../types"; +import { useApp } from "./AppContext"; +import { Notice } from "obsidian"; + +const RelationshipSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { + const [discourseRelations, setDiscourseRelations] = useState< + DiscourseRelation[] + >(() => plugin.settings.discourseRelations ?? []); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + useEffect(() => { + const initializeSettings = async () => { + let needsSave = false; + + if (!plugin.settings.discourseRelations) { + plugin.settings.discourseRelations = []; + needsSave = true; + } + + if (needsSave) { + await plugin.saveSettings(); + } + }; + + initializeSettings(); + }, [plugin]); + + const handleRelationChange = async ( + index: number, + field: keyof DiscourseRelation, + value: string, + ): Promise => { + const updatedRelations = [...discourseRelations]; + if (!updatedRelations[index]) { + updatedRelations[index] = { + source: { name: value, format: "markdown" }, + destination: { name: value, format: "markdown" }, + relationshipType: { id: value, label: "", complement: "" }, + }; + } else { + updatedRelations[index] = { + ...updatedRelations[index], + [field]: value, + }; + } + + setDiscourseRelations(updatedRelations); + setHasUnsavedChanges(true); + }; + + const handleAddRelation = (): void => { + const updatedRelations = [ + ...discourseRelations, + { + source: { name: "", format: "markdown" }, + destination: { name: "", format: "markdown" }, + relationshipType: { id: "", label: "", complement: "" }, + }, + ]; + setDiscourseRelations(updatedRelations); + setHasUnsavedChanges(true); + }; + + const handleDeleteRelation = async (index: number): Promise => { + const updatedRelations = discourseRelations.filter((_, i) => i !== index); + setDiscourseRelations(updatedRelations); + plugin.settings.discourseRelations = updatedRelations; + await plugin.saveSettings(); + await plugin.loadSettings(); + }; + + const getRelationLabel = (relation: DiscourseRelation): string => { + const relationType = plugin.settings.relationTypes.find( + (rt) => rt.id === relation.relationshipType.id, + ); + + if (!relationType) return "Invalid relation"; + + const sourceType = plugin.settings.nodeTypes.find( + (nt) => nt.name === relation.source.name, + ); + + const targetType = plugin.settings.nodeTypes.find( + (nt) => nt.name === relation.destination.name, + ); + + if (!sourceType || !targetType) return "Invalid node types"; + + return `${sourceType.name} ${relationType.label} ${targetType.name}`; + }; + + const getComplementLabel = (relation: DiscourseRelation): string => { + const relationType = plugin.settings.relationTypes.find( + (rt) => rt.id === relation.relationshipType.id, + ); + + if (!relationType) return "Invalid relation"; + + const sourceType = plugin.settings.nodeTypes.find( + (nt) => nt.name === relation.source.name, + ); + + const targetType = plugin.settings.nodeTypes.find( + (nt) => nt.name === relation.destination.name, + ); + + if (!sourceType || !targetType) return "Invalid node types"; + + return `${targetType.name} ${relationType.complement} ${sourceType.name}`; + }; + + const handleSave = async (): Promise => { + // Validate relations + for (const relation of discourseRelations) { + if ( + !relation.relationshipType.id || + !relation.source.name || + !relation.destination.name + ) { + new Notice("All fields are required for relations."); + return; + } + } + + // Check for duplicate relations + const relationKeys = discourseRelations.map( + (r) => `${r.relationshipType.id}-${r.source.name}-${r.destination.name}`, + ); + if (new Set(relationKeys).size !== relationKeys.length) { + new Notice("Duplicate relations are not allowed."); + return; + } + + plugin.settings.discourseRelations = discourseRelations; + await plugin.saveSettings(); + await plugin.loadSettings(); + 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. +
+
+
+ )} + + {plugin.settings.nodeTypes.length > 0 && + plugin.settings.relationTypes.length > 0 && ( + <> + {discourseRelations.map((relation, index) => ( +
+
+
+ + + + + + + +
+ + {relation.source.name && + relation.relationshipType.id && + relation.destination.name && ( +
+
Forward: {getRelationLabel(relation)}
+
Reverse: {getComplementLabel(relation)}
+
+ )} +
+
+ ))} + +
+
+ + +
+
+ + {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..e6de037ef --- /dev/null +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -0,0 +1,172 @@ +import { useState, useEffect } from "react"; +import type DiscourseGraphPlugin from "../index"; +import { DiscourseRelationType } from "../types"; +import { useApp } from "./AppContext"; +import { Notice } from "obsidian"; + +const RelationshipTypeSettings = ({ + plugin, +}: { + plugin: DiscourseGraphPlugin; +}) => { + const [relationTypes, setRelationTypes] = useState( + () => plugin.settings.relationTypes ?? [], + ); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + useEffect(() => { + const initializeSettings = async () => { + let needsSave = false; + + if (!plugin.settings.relationTypes) { + plugin.settings.relationTypes = []; + needsSave = true; + } + + if (needsSave) { + await plugin.saveSettings(); + } + }; + + initializeSettings(); + }, [plugin]); + + const handleRelationTypeChange = async ( + index: number, + field: keyof DiscourseRelationType, + value: string, + ): Promise => { + const updatedRelationTypes = [...relationTypes]; + if (!updatedRelationTypes[index]) { + updatedRelationTypes[index] = { id: "", label: "", complement: "" }; + } + + updatedRelationTypes[index][field] = value; + setRelationTypes(updatedRelationTypes); + setHasUnsavedChanges(true); + }; + + const handleAddRelationType = (): void => { + const updatedRelationTypes = [ + ...relationTypes, + { + id: "", + label: "", + complement: "", + }, + ]; + setRelationTypes(updatedRelationTypes); + setHasUnsavedChanges(true); + }; + + const handleDeleteRelationType = async (index: number): Promise => { + // Check if this relation type is used in any relations + const isUsed = plugin.settings.discourseRelations?.some( + (rel) => rel.relationshipType.id === 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(); + await plugin.loadSettings(); + }; + + const handleSave = async (): Promise => { + // Validate relation types + for (const relType of relationTypes) { + if (!relType.id || !relType.label || !relType.complement) { + new Notice("All fields are required for relation types."); + return; + } + } + + // Check for duplicate IDs + const ids = relationTypes.map((rt) => rt.id); + if (new Set(ids).size !== ids.length) { + new Notice("Relation type IDs must be unique."); + return; + } + + plugin.settings.relationTypes = relationTypes; + await plugin.saveSettings(); + console.log("new relations type", plugin.settings.relationTypes); + // await plugin.loadSettings(); + setHasUnsavedChanges(false); + }; + + return ( +
+

Relation Types

+ {relationTypes.map((relationType, index) => ( +
+
+
+ + handleRelationTypeChange(index, "id", e.target.value) + } + style={{ flex: 1 }} + /> + + 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..dd182ac51 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -1,9 +1,11 @@ import { StrictMode, useState, useEffect } from "react"; -import { App, PluginSettingTab } from "obsidian"; +import { App, PluginSettingTab, Notice } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; import { validateNodeFormat } from "../utils/validateNodeFormat"; +import RelationshipTypeSettings from "./RelationshipTypeSettings"; +import RelationshipSettings from "./RelationshipSettings"; const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { const [nodeTypes, setNodeTypes] = useState( @@ -173,9 +175,85 @@ const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { }; const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { + const [activeTab, setActiveTab] = useState("nodeTypes"); + return (
- +

Discourse Graph Settings

+ +
+ + + +
+ + {activeTab === "nodeTypes" && } + {activeTab === "relationTypes" && ( + + )} + {activeTab === "relations" && }
); }; 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/types.ts b/apps/obsidian/src/types.ts index 208afc132..f2773443f 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -1,10 +1,24 @@ -export type DiscourseNodeType = { +export type DiscourseNode = { name: string; format: string; shortcut?: string; color?: string; }; +export type DiscourseRelationType = { + id: string; + label: string; + complement: string; +}; + +export type DiscourseRelation = { + source: DiscourseNode; + destination: DiscourseNode; + relationshipType: DiscourseRelationType; +}; + export type Settings = { - nodeTypes: DiscourseNodeType[]; + nodeTypes: DiscourseNode[]; + discourseRelations: DiscourseRelation[]; + relationTypes: DiscourseRelationType[]; }; From e00c251230e73dd33ec61deabc02f74eb6f0ce17 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 5 Mar 2025 13:02:34 -0500 Subject: [PATCH 15/23] fix the bug. now relationship is updated --- .../src/components/NodeTypeSettings.tsx | 156 ++++++++++++++++++ .../src/components/RelationshipSettings.tsx | 42 ++++- 2 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 apps/obsidian/src/components/NodeTypeSettings.tsx diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx new file mode 100644 index 000000000..1dda571e1 --- /dev/null +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from "react"; +import { useSettingsContext } from "./SettingsContext"; +import { validateNodeFormat } from "../utils/validateNodeFormat"; + +const NodeTypeSettings = () => { + const { + nodeTypes, + setNodeTypes, + hasUnsavedChanges, + setHasUnsavedChanges, + saveSettings, + } = useSettingsContext(); + + const [formatErrors, setFormatErrors] = useState>({}); + + const handleNodeTypeChange = async ( + index: number, + field: "name" | "format" | "shortcut" | "color", + 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); + setHasUnsavedChanges(true); + }; + + 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; + } + + await saveSettings("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]} +
+ )} +
+
+ ))} +
+
+ + +
+
+ {hasUnsavedChanges && ( +
+ You have unsaved changes +
+ )} +
+ ); +}; + +export default NodeTypeSettings; diff --git a/apps/obsidian/src/components/RelationshipSettings.tsx b/apps/obsidian/src/components/RelationshipSettings.tsx index a9866731c..e38618589 100644 --- a/apps/obsidian/src/components/RelationshipSettings.tsx +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -5,7 +5,6 @@ import { DiscourseNode, DiscourseRelationType, } from "../types"; -import { useApp } from "./AppContext"; import { Notice } from "obsidian"; const RelationshipSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { @@ -39,15 +38,42 @@ const RelationshipSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { const updatedRelations = [...discourseRelations]; if (!updatedRelations[index]) { updatedRelations[index] = { - source: { name: value, format: "markdown" }, - destination: { name: value, format: "markdown" }, - relationshipType: { id: value, label: "", complement: "" }, + source: { name: "", format: "markdown" }, + destination: { name: "", format: "markdown" }, + relationshipType: { id: "", label: "", complement: "" }, }; - } else { - updatedRelations[index] = { - ...updatedRelations[index], - [field]: value, + } + + // Handle each field type appropriately + if (field === "source" || field === "destination") { + // For source and destination, update the node's name + // Find the matching node type to get its format + const nodeType = plugin.settings.nodeTypes.find( + (nt) => nt.name === value, + ); + updatedRelations[index][field] = { + name: value, + format: nodeType?.format || "markdown", }; + } else if (field === "relationshipType") { + // For relationshipType, we get an ID and need to find the complete relation type + const relationType = plugin.settings.relationTypes.find( + (rt) => rt.id === value, + ); + if (relationType) { + updatedRelations[index].relationshipType = { + id: relationType.id, + label: relationType.label, + complement: relationType.complement, + }; + } else { + // If not found, just update the ID + updatedRelations[index].relationshipType = { + id: value, + label: "", + complement: "", + }; + } } setDiscourseRelations(updatedRelations); From 2c44ed9d2ee158f6fde7b3ac18ecb7530f61db51 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 5 Mar 2025 13:44:14 -0500 Subject: [PATCH 16/23] change the style to show bidirectional relations visually --- .../src/components/NodeTypeSettings.tsx | 42 +++-- .../src/components/RelationshipSettings.tsx | 83 ++++++++- apps/obsidian/src/components/Settings.tsx | 172 +----------------- apps/obsidian/styles.css | 20 +- 4 files changed, 131 insertions(+), 186 deletions(-) diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx index 1dda571e1..0e7d06fdb 100644 --- a/apps/obsidian/src/components/NodeTypeSettings.tsx +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -1,21 +1,34 @@ import { useState, useEffect } from "react"; -import { useSettingsContext } from "./SettingsContext"; +import type DiscourseGraphPlugin from "../index"; import { validateNodeFormat } from "../utils/validateNodeFormat"; -const NodeTypeSettings = () => { - const { - nodeTypes, - setNodeTypes, - hasUnsavedChanges, - setHasUnsavedChanges, - saveSettings, - } = useSettingsContext(); - +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" | "shortcut" | "color", + field: "name" | "format", value: string, ): Promise => { const updatedNodeTypes = [...nodeTypes]; @@ -59,7 +72,8 @@ const NodeTypeSettings = () => { const handleDeleteNodeType = async (index: number): Promise => { const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index); setNodeTypes(updatedNodeTypes); - setHasUnsavedChanges(true); + plugin.settings.nodeTypes = updatedNodeTypes; + await plugin.saveSettings(); }; const handleSave = async (): Promise => { @@ -79,7 +93,9 @@ const NodeTypeSettings = () => { return; } - await saveSettings("nodeTypes"); + plugin.settings.nodeTypes = nodeTypes; + await plugin.saveSettings(); + setHasUnsavedChanges(false); }; return ( diff --git a/apps/obsidian/src/components/RelationshipSettings.tsx b/apps/obsidian/src/components/RelationshipSettings.tsx index e38618589..cc3237964 100644 --- a/apps/obsidian/src/components/RelationshipSettings.tsx +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -274,10 +274,89 @@ const RelationshipSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { style={{ marginTop: "8px", color: "var(--text-normal)", + border: "1px solid var(--background-modifier-border)", + borderRadius: "4px", + padding: "8px", + background: "var(--background-secondary)", }} + className="relationship-visualization" > -
Forward: {getRelationLabel(relation)}
-
Reverse: {getComplementLabel(relation)}
+
+
+ {relation.source.name} +
+ +
+
+
+ {relation.relationshipType.label} +
+
+ → +
+
+
+
+
+ ← +
+
+ {relation.relationshipType.complement} +
+
+
+ +
+ {relation.destination.name} +
+
)}
diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index dd182ac51..d0142936a 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -3,176 +3,10 @@ import { App, PluginSettingTab, Notice } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; import { ContextProvider, useApp } from "./AppContext"; -import { validateNodeFormat } from "../utils/validateNodeFormat"; import RelationshipTypeSettings from "./RelationshipTypeSettings"; import RelationshipSettings from "./RelationshipSettings"; - -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); - }; - - 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 -
- )} -
- ); -}; +import NodeTypeSettings from "./NodeTypeSettings"; +import React from "react"; const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { const [activeTab, setActiveTab] = useState("nodeTypes"); @@ -245,7 +79,7 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { cursor: "pointer", }} > - Relations + Discourse Relations
diff --git a/apps/obsidian/styles.css b/apps/obsidian/styles.css index 26f590cea..2827692f0 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -1,3 +1,19 @@ -.modal-content { - background-color: red; +.relationship-node { + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 6px 10px; + font-weight: 500; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + min-width: 100px; + text-align: center; +} + +.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); } From 4098e580c12e92e685baca76f4abbf728e95befd Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 5 Mar 2025 14:01:17 -0500 Subject: [PATCH 17/23] create plugin as context instead of passing in props --- .../src/components/NodeTypeSettings.tsx | 4 ++- .../obsidian/src/components/PluginContext.tsx | 26 +++++++++++++++++++ .../src/components/RelationshipSettings.tsx | 11 +++----- .../components/RelationshipTypeSettings.tsx | 10 +++---- apps/obsidian/src/components/Settings.tsx | 17 ++++++------ 5 files changed, 45 insertions(+), 23 deletions(-) create mode 100644 apps/obsidian/src/components/PluginContext.tsx diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx index 0e7d06fdb..cb15f1be9 100644 --- a/apps/obsidian/src/components/NodeTypeSettings.tsx +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -1,8 +1,10 @@ import { useState, useEffect } from "react"; import type DiscourseGraphPlugin from "../index"; import { validateNodeFormat } from "../utils/validateNodeFormat"; +import { usePlugin } from "./PluginContext"; -const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { +const NodeTypeSettings = () => { + const plugin = usePlugin(); const [nodeTypes, setNodeTypes] = useState( () => plugin.settings.nodeTypes ?? [], ); diff --git a/apps/obsidian/src/components/PluginContext.tsx b/apps/obsidian/src/components/PluginContext.tsx new file mode 100644 index 000000000..6e455a49e --- /dev/null +++ b/apps/obsidian/src/components/PluginContext.tsx @@ -0,0 +1,26 @@ +import React, { 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 index cc3237964..b5b751fe5 100644 --- a/apps/obsidian/src/components/RelationshipSettings.tsx +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -1,13 +1,10 @@ import { useState, useEffect } from "react"; -import type DiscourseGraphPlugin from "../index"; -import { - DiscourseRelation, - DiscourseNode, - DiscourseRelationType, -} from "../types"; +import { DiscourseRelation } from "../types"; import { Notice } from "obsidian"; +import { usePlugin } from "./PluginContext"; -const RelationshipSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { +const RelationshipSettings = () => { + const plugin = usePlugin(); const [discourseRelations, setDiscourseRelations] = useState< DiscourseRelation[] >(() => plugin.settings.discourseRelations ?? []); diff --git a/apps/obsidian/src/components/RelationshipTypeSettings.tsx b/apps/obsidian/src/components/RelationshipTypeSettings.tsx index e6de037ef..a33d1828c 100644 --- a/apps/obsidian/src/components/RelationshipTypeSettings.tsx +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -1,14 +1,10 @@ import { useState, useEffect } from "react"; -import type DiscourseGraphPlugin from "../index"; import { DiscourseRelationType } from "../types"; -import { useApp } from "./AppContext"; import { Notice } from "obsidian"; +import { usePlugin } from "./PluginContext"; -const RelationshipTypeSettings = ({ - plugin, -}: { - plugin: DiscourseGraphPlugin; -}) => { +const RelationshipTypeSettings = () => { + const plugin = usePlugin(); const [relationTypes, setRelationTypes] = useState( () => plugin.settings.relationTypes ?? [], ); diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index d0142936a..0fb8d9629 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -6,9 +6,10 @@ import { ContextProvider, useApp } from "./AppContext"; import RelationshipTypeSettings from "./RelationshipTypeSettings"; import RelationshipSettings from "./RelationshipSettings"; import NodeTypeSettings from "./NodeTypeSettings"; -import React from "react"; +import { PluginProvider, usePlugin } from "./PluginContext"; -const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => { +const Settings = () => { + const plugin = usePlugin(); const [activeTab, setActiveTab] = useState("nodeTypes"); return ( @@ -83,11 +84,9 @@ const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => {
- {activeTab === "nodeTypes" && } - {activeTab === "relationTypes" && ( - - )} - {activeTab === "relations" && } + {activeTab === "nodeTypes" && } + {activeTab === "relationTypes" && } + {activeTab === "relations" && }
); }; @@ -109,7 +108,9 @@ export class SettingsTab extends PluginSettingTab { this.root.render( - + + + , ); From 9ff13f5525b4f5dbbe9e5dd0f91062e71f3fe047 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 6 Mar 2025 16:53:58 -0500 Subject: [PATCH 18/23] new type definitions + settings finished --- .../src/components/NodeTypeSettings.tsx | 94 +++++---- .../src/components/RelationshipSettings.tsx | 199 +++++------------- .../components/RelationshipTypeSettings.tsx | 50 ++--- apps/obsidian/src/types.ts | 7 +- apps/obsidian/src/utils/generateUid.ts | 5 + apps/obsidian/src/utils/validateNodeFormat.ts | 80 ++++++- 6 files changed, 207 insertions(+), 228 deletions(-) create mode 100644 apps/obsidian/src/utils/generateUid.ts diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx index cb15f1be9..d85d9e3a0 100644 --- a/apps/obsidian/src/components/NodeTypeSettings.tsx +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -1,7 +1,13 @@ -import { useState, useEffect } from "react"; -import type DiscourseGraphPlugin from "../index"; -import { validateNodeFormat } from "../utils/validateNodeFormat"; +import { useState } from "react"; +import { + validateAllNodes, + validateNodeFormat, + validateNodeName, +} from "../utils/validateNodeFormat"; import { usePlugin } from "./PluginContext"; +import { Notice } from "obsidian"; +import generateUid from "~/utils/generateUid"; +import { DiscourseNode } from "~/types"; const NodeTypeSettings = () => { const plugin = usePlugin(); @@ -11,43 +17,39 @@ const NodeTypeSettings = () => { 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", + field: keyof DiscourseNode, value: string, ): Promise => { const updatedNodeTypes = [...nodeTypes]; if (!updatedNodeTypes[index]) { - updatedNodeTypes[index] = { name: "", format: "" }; + const newId = generateUid("node"); + updatedNodeTypes[index] = { id: newId, name: "", format: "" }; } updatedNodeTypes[index][field] = value; - setNodeTypes(updatedNodeTypes); - setHasUnsavedChanges(true); if (field === "format") { - const { isValid, error } = validateNodeFormat(value); + const { isValid, error } = validateNodeFormat(value, updatedNodeTypes); if (!isValid) { setFormatErrors((prev) => ({ ...prev, - [index]: error ?? "Invalid format", + [index]: error || "Invalid format", + })); + } else { + setFormatErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[index]; + return newErrors; + }); + } + } else if (field === "name") { + const nameValidation = validateNodeName(value, updatedNodeTypes); + if (!nameValidation.isValid) { + setFormatErrors((prev) => ({ + ...prev, + [index]: nameValidation.error || "Invalid name", })); } else { setFormatErrors((prev) => { @@ -57,12 +59,17 @@ const NodeTypeSettings = () => { }); } } + + setNodeTypes(updatedNodeTypes); + setHasUnsavedChanges(true); }; const handleAddNodeType = (): void => { + const newId = generateUid("node"); const updatedNodeTypes = [ ...nodeTypes, { + id: newId, name: "", format: "", }, @@ -72,31 +79,42 @@ const NodeTypeSettings = () => { }; 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; + }); + } }; 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; - } - } + 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); }; diff --git a/apps/obsidian/src/components/RelationshipSettings.tsx b/apps/obsidian/src/components/RelationshipSettings.tsx index b5b751fe5..bd61c2964 100644 --- a/apps/obsidian/src/components/RelationshipSettings.tsx +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -1,5 +1,9 @@ import { useState, useEffect } from "react"; -import { DiscourseRelation } from "../types"; +import { + DiscourseRelation, + DiscourseNode, + DiscourseRelationType, +} from "../types"; import { Notice } from "obsidian"; import { usePlugin } from "./PluginContext"; @@ -10,69 +14,32 @@ const RelationshipSettings = () => { >(() => plugin.settings.discourseRelations ?? []); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - useEffect(() => { - const initializeSettings = async () => { - let needsSave = false; - - if (!plugin.settings.discourseRelations) { - plugin.settings.discourseRelations = []; - needsSave = true; - } - - if (needsSave) { - await plugin.saveSettings(); - } - }; + const findNodeById = (id: string): DiscourseNode | undefined => { + return plugin.settings.nodeTypes.find((node) => node.id === id); + }; - initializeSettings(); - }, [plugin]); + const findRelationTypeById = ( + id: string, + ): DiscourseRelationType | undefined => { + return plugin.settings.relationTypes.find((relType) => relType.id === id); + }; const handleRelationChange = async ( index: number, - field: keyof DiscourseRelation, + field: "sourceId" | "destinationId" | "relationshipTypeId", value: string, ): Promise => { const updatedRelations = [...discourseRelations]; + if (!updatedRelations[index]) { updatedRelations[index] = { - source: { name: "", format: "markdown" }, - destination: { name: "", format: "markdown" }, - relationshipType: { id: "", label: "", complement: "" }, + sourceId: "", + destinationId: "", + relationshipTypeId: "", }; } - // Handle each field type appropriately - if (field === "source" || field === "destination") { - // For source and destination, update the node's name - // Find the matching node type to get its format - const nodeType = plugin.settings.nodeTypes.find( - (nt) => nt.name === value, - ); - updatedRelations[index][field] = { - name: value, - format: nodeType?.format || "markdown", - }; - } else if (field === "relationshipType") { - // For relationshipType, we get an ID and need to find the complete relation type - const relationType = plugin.settings.relationTypes.find( - (rt) => rt.id === value, - ); - if (relationType) { - updatedRelations[index].relationshipType = { - id: relationType.id, - label: relationType.label, - complement: relationType.complement, - }; - } else { - // If not found, just update the ID - updatedRelations[index].relationshipType = { - id: value, - label: "", - complement: "", - }; - } - } - + updatedRelations[index][field] = value; setDiscourseRelations(updatedRelations); setHasUnsavedChanges(true); }; @@ -81,9 +48,9 @@ const RelationshipSettings = () => { const updatedRelations = [ ...discourseRelations, { - source: { name: "", format: "markdown" }, - destination: { name: "", format: "markdown" }, - relationshipType: { id: "", label: "", complement: "" }, + sourceId: "", + destinationId: "", + relationshipTypeId: "", }, ]; setDiscourseRelations(updatedRelations); @@ -95,65 +62,23 @@ const RelationshipSettings = () => { setDiscourseRelations(updatedRelations); plugin.settings.discourseRelations = updatedRelations; await plugin.saveSettings(); - await plugin.loadSettings(); - }; - - const getRelationLabel = (relation: DiscourseRelation): string => { - const relationType = plugin.settings.relationTypes.find( - (rt) => rt.id === relation.relationshipType.id, - ); - - if (!relationType) return "Invalid relation"; - - const sourceType = plugin.settings.nodeTypes.find( - (nt) => nt.name === relation.source.name, - ); - - const targetType = plugin.settings.nodeTypes.find( - (nt) => nt.name === relation.destination.name, - ); - - if (!sourceType || !targetType) return "Invalid node types"; - - return `${sourceType.name} ${relationType.label} ${targetType.name}`; - }; - - const getComplementLabel = (relation: DiscourseRelation): string => { - const relationType = plugin.settings.relationTypes.find( - (rt) => rt.id === relation.relationshipType.id, - ); - - if (!relationType) return "Invalid relation"; - - const sourceType = plugin.settings.nodeTypes.find( - (nt) => nt.name === relation.source.name, - ); - - const targetType = plugin.settings.nodeTypes.find( - (nt) => nt.name === relation.destination.name, - ); - - if (!sourceType || !targetType) return "Invalid node types"; - - return `${targetType.name} ${relationType.complement} ${sourceType.name}`; + new Notice("Relation deleted"); }; const handleSave = async (): Promise => { - // Validate relations for (const relation of discourseRelations) { if ( - !relation.relationshipType.id || - !relation.source.name || - !relation.destination.name + !relation.relationshipTypeId || + !relation.sourceId || + !relation.destinationId ) { new Notice("All fields are required for relations."); return; } } - // Check for duplicate relations const relationKeys = discourseRelations.map( - (r) => `${r.relationshipType.id}-${r.source.name}-${r.destination.name}`, + (r) => `${r.relationshipTypeId}-${r.sourceId}-${r.destinationId}`, ); if (new Set(relationKeys).size !== relationKeys.length) { new Notice("Duplicate relations are not allowed."); @@ -162,7 +87,7 @@ const RelationshipSettings = () => { plugin.settings.discourseRelations = discourseRelations; await plugin.saveSettings(); - await plugin.loadSettings(); + new Notice("Relations saved"); setHasUnsavedChanges(false); }; @@ -204,26 +129,26 @@ const RelationshipSettings = () => { >
- handleRelationTypeChange(index, "id", e.target.value) - } - style={{ flex: 1 }} - /> { + return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +}; + +export default generateUid; diff --git a/apps/obsidian/src/utils/validateNodeFormat.ts b/apps/obsidian/src/utils/validateNodeFormat.ts index da73e4afd..816cb9874 100644 --- a/apps/obsidian/src/utils/validateNodeFormat.ts +++ b/apps/obsidian/src/utils/validateNodeFormat.ts @@ -1,4 +1,9 @@ -export function validateNodeFormat(format: string): { +import { DiscourseNode } from "~/types"; + +export function validateNodeFormat( + format: string, + nodeTypes: DiscourseNode[], +): { isValid: boolean; error?: string; } { @@ -16,13 +21,80 @@ export function validateNodeFormat(format: string): { }; } - const hasVariable = /{[a-zA-Z]+}/.test(format); - if (!hasVariable) { + if (!format.includes("{content}")) { return { isValid: false, - error: "Format must contain at least one variable in {varName} format", + 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 }; +}; From 88466b5daffe15c4237f257a9946a725c93b65d0 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 6 Mar 2025 17:01:30 -0500 Subject: [PATCH 19/23] rename --- apps/obsidian/src/components/NodeTypeSettings.tsx | 2 +- .../src/utils/{validateNodeFormat.ts => validateNodeType.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/obsidian/src/utils/{validateNodeFormat.ts => validateNodeType.ts} (100%) diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx index d85d9e3a0..f5cd815a7 100644 --- a/apps/obsidian/src/components/NodeTypeSettings.tsx +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -3,7 +3,7 @@ import { validateAllNodes, validateNodeFormat, validateNodeName, -} from "../utils/validateNodeFormat"; +} from "../utils/validateNodeType"; import { usePlugin } from "./PluginContext"; import { Notice } from "obsidian"; import generateUid from "~/utils/generateUid"; diff --git a/apps/obsidian/src/utils/validateNodeFormat.ts b/apps/obsidian/src/utils/validateNodeType.ts similarity index 100% rename from apps/obsidian/src/utils/validateNodeFormat.ts rename to apps/obsidian/src/utils/validateNodeType.ts From 0dbb1889f6a81f52e5e84b11296dbc5130fe6d15 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 6 Mar 2025 17:08:06 -0500 Subject: [PATCH 20/23] check for duplicates --- apps/obsidian/src/components/RelationshipTypeSettings.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/obsidian/src/components/RelationshipTypeSettings.tsx b/apps/obsidian/src/components/RelationshipTypeSettings.tsx index 574491c9d..b8f4b9c9b 100644 --- a/apps/obsidian/src/components/RelationshipTypeSettings.tsx +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -75,6 +75,12 @@ const RelationshipTypeSettings = () => { 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); From c42d587f06f31dbe1a3276f6db1d126000f7a3f0 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 6 Mar 2025 17:16:02 -0500 Subject: [PATCH 21/23] address PR comments --- .../src/components/NodeTypeSettings.tsx | 44 +++++++++---------- .../src/components/RelationshipSettings.tsx | 4 +- .../components/RelationshipTypeSettings.tsx | 1 - apps/obsidian/src/components/Settings.tsx | 8 ++-- 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx index f5cd815a7..2b80ded42 100644 --- a/apps/obsidian/src/components/NodeTypeSettings.tsx +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -17,6 +17,24 @@ const NodeTypeSettings = () => { 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, @@ -32,32 +50,10 @@ const NodeTypeSettings = () => { if (field === "format") { const { isValid, error } = validateNodeFormat(value, updatedNodeTypes); - if (!isValid) { - setFormatErrors((prev) => ({ - ...prev, - [index]: error || "Invalid format", - })); - } else { - setFormatErrors((prev) => { - const newErrors = { ...prev }; - delete newErrors[index]; - return newErrors; - }); - } + updateErrors(index, { isValid, error }); } else if (field === "name") { const nameValidation = validateNodeName(value, updatedNodeTypes); - if (!nameValidation.isValid) { - setFormatErrors((prev) => ({ - ...prev, - [index]: nameValidation.error || "Invalid name", - })); - } else { - setFormatErrors((prev) => { - const newErrors = { ...prev }; - delete newErrors[index]; - return newErrors; - }); - } + updateErrors(index, nameValidation); } setNodeTypes(updatedNodeTypes); diff --git a/apps/obsidian/src/components/RelationshipSettings.tsx b/apps/obsidian/src/components/RelationshipSettings.tsx index bd61c2964..710ce7f53 100644 --- a/apps/obsidian/src/components/RelationshipSettings.tsx +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { DiscourseRelation, DiscourseNode, @@ -26,7 +26,7 @@ const RelationshipSettings = () => { const handleRelationChange = async ( index: number, - field: "sourceId" | "destinationId" | "relationshipTypeId", + field: keyof DiscourseRelation, value: string, ): Promise => { const updatedRelations = [...discourseRelations]; diff --git a/apps/obsidian/src/components/RelationshipTypeSettings.tsx b/apps/obsidian/src/components/RelationshipTypeSettings.tsx index b8f4b9c9b..ab953a2d4 100644 --- a/apps/obsidian/src/components/RelationshipTypeSettings.tsx +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -58,7 +58,6 @@ const RelationshipTypeSettings = () => { setRelationTypes(updatedRelationTypes); plugin.settings.relationTypes = updatedRelationTypes; await plugin.saveSettings(); - await plugin.loadSettings(); }; const handleSave = async (): Promise => { diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index 0fb8d9629..e0dd48cb0 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -1,8 +1,8 @@ -import { StrictMode, useState, useEffect } from "react"; -import { App, PluginSettingTab, Notice } from "obsidian"; +import { StrictMode, useState } from "react"; +import { App, PluginSettingTab } from "obsidian"; import type DiscourseGraphPlugin from "../index"; import { Root, createRoot } from "react-dom/client"; -import { ContextProvider, useApp } from "./AppContext"; +import { ContextProvider } from "./AppContext"; import RelationshipTypeSettings from "./RelationshipTypeSettings"; import RelationshipSettings from "./RelationshipSettings"; import NodeTypeSettings from "./NodeTypeSettings"; @@ -122,4 +122,4 @@ export class SettingsTab extends PluginSettingTab { this.root = null; } } -} \ No newline at end of file +} From 585b6312eed8d520c1488bc29bcffd2d2b999cbe Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 17 Mar 2025 13:08:14 -0400 Subject: [PATCH 22/23] current progress --- .../src/components/NodeTypeSettings.tsx | 40 ++++++++++--- .../obsidian/src/components/PluginContext.tsx | 4 +- .../src/components/RelationshipSettings.tsx | 57 +++++++++++-------- .../components/RelationshipTypeSettings.tsx | 42 +++++++++++--- apps/obsidian/src/components/Settings.tsx | 5 +- apps/obsidian/src/styles/style.css | 19 +++++++ apps/obsidian/src/utils/generateUid.ts | 4 +- apps/obsidian/src/utils/registerCommands.ts | 10 +--- apps/obsidian/src/utils/validateNodeFormat.ts | 8 ++- package-lock.json | 2 +- 10 files changed, 133 insertions(+), 58 deletions(-) create mode 100644 apps/obsidian/src/styles/style.css diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx index 2b80ded42..c32029a1f 100644 --- a/apps/obsidian/src/components/NodeTypeSettings.tsx +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -3,7 +3,7 @@ import { validateAllNodes, validateNodeFormat, validateNodeName, -} from "../utils/validateNodeType"; +} from "~/utils/validateNodeType"; import { usePlugin } from "./PluginContext"; import { Notice } from "obsidian"; import generateUid from "~/utils/generateUid"; @@ -16,6 +16,9 @@ const NodeTypeSettings = () => { ); const [formatErrors, setFormatErrors] = useState>({}); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [pendingDeleteIndex, setPendingDeleteIndex] = useState( + null, + ); const updateErrors = ( index: number, @@ -74,6 +77,14 @@ const NodeTypeSettings = () => { setHasUnsavedChanges(true); }; + const confirmDeleteNodeType = (index: number): void => { + setPendingDeleteIndex(index); + }; + + const cancelDelete = (): void => { + setPendingDeleteIndex(null); + }; + const handleDeleteNodeType = async (index: number): Promise => { const nodeId = nodeTypes[index]?.id; const isUsed = plugin.settings.discourseRelations?.some( @@ -84,6 +95,7 @@ const NodeTypeSettings = () => { new Notice( "Cannot delete this node type as it is used in one or more relations.", ); + setPendingDeleteIndex(null); return; } @@ -98,6 +110,8 @@ const NodeTypeSettings = () => { return newErrors; }); } + setPendingDeleteIndex(null); + new Notice("Node type deleted successfully"); }; const handleSave = async (): Promise => { @@ -141,12 +155,24 @@ const NodeTypeSettings = () => { } style={{ flex: 2 }} /> - + {pendingDeleteIndex === index ? ( + <> + + + + ) : ( + + )}
{formatErrors[index] && (
( undefined, diff --git a/apps/obsidian/src/components/RelationshipSettings.tsx b/apps/obsidian/src/components/RelationshipSettings.tsx index 710ce7f53..beff3f9a5 100644 --- a/apps/obsidian/src/components/RelationshipSettings.tsx +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -3,7 +3,7 @@ import { DiscourseRelation, DiscourseNode, DiscourseRelationType, -} from "../types"; +} from "~/types"; import { Notice } from "obsidian"; import { usePlugin } from "./PluginContext"; @@ -13,6 +13,9 @@ const RelationshipSettings = () => { DiscourseRelation[] >(() => plugin.settings.discourseRelations ?? []); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [pendingDeleteIndex, setPendingDeleteIndex] = useState( + null, + ); const findNodeById = (id: string): DiscourseNode | undefined => { return plugin.settings.nodeTypes.find((node) => node.id === id); @@ -54,14 +57,24 @@ const RelationshipSettings = () => { }, ]; setDiscourseRelations(updatedRelations); + console.log("updatedRelations", updatedRelations); setHasUnsavedChanges(true); }; + const confirmDeleteRelation = (index: number): void => { + setPendingDeleteIndex(index); + }; + + const cancelDelete = (): void => { + setPendingDeleteIndex(null); + }; + const handleDeleteRelation = async (index: number): Promise => { const updatedRelations = discourseRelations.filter((_, i) => i !== index); setDiscourseRelations(updatedRelations); plugin.settings.discourseRelations = updatedRelations; await plugin.saveSettings(); + setPendingDeleteIndex(null); new Notice("Relation deleted"); }; @@ -96,23 +109,7 @@ const RelationshipSettings = () => {

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. -
-
-
+
You need to create some node types first.
)} {plugin.settings.nodeTypes.length > 0 && @@ -181,12 +178,24 @@ const RelationshipSettings = () => { ))} - + {pendingDeleteIndex === index ? ( + <> + + + + ) : ( + + )}
{relation.sourceId && diff --git a/apps/obsidian/src/components/RelationshipTypeSettings.tsx b/apps/obsidian/src/components/RelationshipTypeSettings.tsx index ab953a2d4..6c00587e8 100644 --- a/apps/obsidian/src/components/RelationshipTypeSettings.tsx +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; -import { DiscourseRelationType } from "../types"; +import { DiscourseRelationType } from "~/types"; import { Notice } from "obsidian"; import { usePlugin } from "./PluginContext"; -import generateUid from "../utils/generateUid"; +import generateUid from "~/utils/generateUid"; const RelationshipTypeSettings = () => { const plugin = usePlugin(); @@ -10,6 +10,9 @@ const RelationshipTypeSettings = () => { () => plugin.settings.relationTypes ?? [], ); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [pendingDeleteIndex, setPendingDeleteIndex] = useState( + null, + ); const handleRelationTypeChange = async ( index: number, @@ -42,6 +45,14 @@ const RelationshipTypeSettings = () => { setHasUnsavedChanges(true); }; + const confirmDeleteRelationType = (index: number): void => { + setPendingDeleteIndex(index); + }; + + const cancelDelete = (): void => { + setPendingDeleteIndex(null); + }; + const handleDeleteRelationType = async (index: number): Promise => { const isUsed = plugin.settings.discourseRelations?.some( (rel) => rel.relationshipTypeId === relationTypes[index]?.id, @@ -51,6 +62,7 @@ const RelationshipTypeSettings = () => { new Notice( "Cannot delete this relation type as it is used in one or more relations.", ); + setPendingDeleteIndex(null); return; } @@ -58,6 +70,8 @@ const RelationshipTypeSettings = () => { setRelationTypes(updatedRelationTypes); plugin.settings.relationTypes = updatedRelationTypes; await plugin.saveSettings(); + setPendingDeleteIndex(null); + new Notice("Relation type deleted successfully"); }; const handleSave = async (): Promise => { @@ -113,12 +127,24 @@ const RelationshipTypeSettings = () => { } style={{ flex: 2 }} /> - + {pendingDeleteIndex === index ? ( + <> + + + + ) : ( + + )}
diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index e0dd48cb0..715425fdb 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -1,15 +1,14 @@ 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 } from "./AppContext"; import RelationshipTypeSettings from "./RelationshipTypeSettings"; import RelationshipSettings from "./RelationshipSettings"; import NodeTypeSettings from "./NodeTypeSettings"; -import { PluginProvider, usePlugin } from "./PluginContext"; +import { PluginProvider } from "./PluginContext"; const Settings = () => { - const plugin = usePlugin(); const [activeTab, setActiveTab] = useState("nodeTypes"); return ( diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css new file mode 100644 index 000000000..2827692f0 --- /dev/null +++ b/apps/obsidian/src/styles/style.css @@ -0,0 +1,19 @@ +.relationship-node { + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 6px 10px; + font-weight: 500; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + min-width: 100px; + text-align: center; +} + +.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/apps/obsidian/src/utils/generateUid.ts b/apps/obsidian/src/utils/generateUid.ts index 3acd95b4e..ae6c9f442 100644 --- a/apps/obsidian/src/utils/generateUid.ts +++ b/apps/obsidian/src/utils/generateUid.ts @@ -1,5 +1,7 @@ +import { nanoid } from "nanoid"; + const generateUid = (prefix = "dg") => { - return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + 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/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": { From c4f591b1a2c77ff5d582aa0970905b7a7eb3c487 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 17 Mar 2025 13:40:05 -0400 Subject: [PATCH 23/23] confirm before delete --- .../src/components/ConfirmationModal.tsx | 55 +++ .../src/components/NodeTypeSettings.tsx | 42 +-- .../src/components/RelationshipSettings.tsx | 337 +++++++++--------- .../components/RelationshipTypeSettings.tsx | 45 +-- apps/obsidian/src/styles/style.css | 19 - apps/obsidian/styles.css | 11 - 6 files changed, 255 insertions(+), 254 deletions(-) create mode 100644 apps/obsidian/src/components/ConfirmationModal.tsx 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 index c32029a1f..f6cf97d9a 100644 --- a/apps/obsidian/src/components/NodeTypeSettings.tsx +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -8,6 +8,7 @@ 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(); @@ -16,9 +17,6 @@ const NodeTypeSettings = () => { ); const [formatErrors, setFormatErrors] = useState>({}); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [pendingDeleteIndex, setPendingDeleteIndex] = useState( - null, - ); const updateErrors = ( index: number, @@ -78,11 +76,13 @@ const NodeTypeSettings = () => { }; const confirmDeleteNodeType = (index: number): void => { - setPendingDeleteIndex(index); - }; - - const cancelDelete = (): void => { - setPendingDeleteIndex(null); + 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 => { @@ -95,7 +95,6 @@ const NodeTypeSettings = () => { new Notice( "Cannot delete this node type as it is used in one or more relations.", ); - setPendingDeleteIndex(null); return; } @@ -110,7 +109,6 @@ const NodeTypeSettings = () => { return newErrors; }); } - setPendingDeleteIndex(null); new Notice("Node type deleted successfully"); }; @@ -155,24 +153,12 @@ const NodeTypeSettings = () => { } style={{ flex: 2 }} /> - {pendingDeleteIndex === index ? ( - <> - - - - ) : ( - - )} + {formatErrors[index] && (
{ const plugin = usePlugin(); @@ -13,9 +14,6 @@ const RelationshipSettings = () => { DiscourseRelation[] >(() => plugin.settings.discourseRelations ?? []); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [pendingDeleteIndex, setPendingDeleteIndex] = useState( - null, - ); const findNodeById = (id: string): DiscourseNode | undefined => { return plugin.settings.nodeTypes.find((node) => node.id === id); @@ -57,16 +55,38 @@ const RelationshipSettings = () => { }, ]; setDiscourseRelations(updatedRelations); - console.log("updatedRelations", updatedRelations); setHasUnsavedChanges(true); }; const confirmDeleteRelation = (index: number): void => { - setPendingDeleteIndex(index); - }; + 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 cancelDelete = (): void => { - setPendingDeleteIndex(null); + const modal = new ConfirmationModal(plugin.app, { + title: "Delete Relation", + message, + onConfirm: () => handleDeleteRelation(index), + }); + modal.open(); }; const handleDeleteRelation = async (index: number): Promise => { @@ -74,7 +94,6 @@ const RelationshipSettings = () => { setDiscourseRelations(updatedRelations); plugin.settings.discourseRelations = updatedRelations; await plugin.saveSettings(); - setPendingDeleteIndex(null); new Notice("Relation deleted"); }; @@ -108,196 +127,178 @@ const RelationshipSettings = () => {

Node Type Relations

- {plugin.settings.nodeTypes.length === 0 && ( + {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) => ( +
+
+
+ - {plugin.settings.nodeTypes.length > 0 && - plugin.settings.relationTypes.length > 0 && ( - <> - {discourseRelations.map((relation, index) => ( -
-
-
- + - + - - - {pendingDeleteIndex === index ? ( - <> - - - - ) : ( - - )} -
+ +
- {relation.sourceId && - relation.relationshipTypeId && - relation.destinationId && ( + {relation.sourceId && + relation.relationshipTypeId && + relation.destinationId && ( +
+
+ {findNodeById(relation.sourceId)?.name || + "Unknown Node"} +
+
-
- {findNodeById(relation.sourceId)?.name || - "Unknown Node"} -
-
-
- {findRelationTypeById( - relation.relationshipTypeId, - )?.label || "Unknown Relation"} -
-
- → -
+ {findRelationTypeById(relation.relationshipTypeId) + ?.label || "Unknown Relation"}
- ←{" "} - - {findRelationTypeById( - relation.relationshipTypeId, - )?.complement || "Unknown Complement"} - + →
- -
- {findNodeById(relation.destinationId)?.name || - "Unknown Node"} +
+ ←{" "} + + {findRelationTypeById(relation.relationshipTypeId) + ?.complement || "Unknown Complement"} +
+ +
+ {findNodeById(relation.destinationId)?.name || + "Unknown Node"} +
- )} -
-
- ))} -
-
- - +
+ )}
- {hasUnsavedChanges && ( -
- You have unsaved changes -
- )} - - )} + ))} +
+
+ + +
+
+ {hasUnsavedChanges && ( +
+ You have unsaved changes +
+ )} + + )}
); }; diff --git a/apps/obsidian/src/components/RelationshipTypeSettings.tsx b/apps/obsidian/src/components/RelationshipTypeSettings.tsx index 6c00587e8..7b71ffb5b 100644 --- a/apps/obsidian/src/components/RelationshipTypeSettings.tsx +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -3,6 +3,7 @@ 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(); @@ -10,9 +11,6 @@ const RelationshipTypeSettings = () => { () => plugin.settings.relationTypes ?? [], ); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [pendingDeleteIndex, setPendingDeleteIndex] = useState( - null, - ); const handleRelationTypeChange = async ( index: number, @@ -46,11 +44,16 @@ const RelationshipTypeSettings = () => { }; const confirmDeleteRelationType = (index: number): void => { - setPendingDeleteIndex(index); - }; - - const cancelDelete = (): void => { - setPendingDeleteIndex(null); + 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 => { @@ -62,7 +65,6 @@ const RelationshipTypeSettings = () => { new Notice( "Cannot delete this relation type as it is used in one or more relations.", ); - setPendingDeleteIndex(null); return; } @@ -70,7 +72,6 @@ const RelationshipTypeSettings = () => { setRelationTypes(updatedRelationTypes); plugin.settings.relationTypes = updatedRelationTypes; await plugin.saveSettings(); - setPendingDeleteIndex(null); new Notice("Relation type deleted successfully"); }; @@ -127,24 +128,12 @@ const RelationshipTypeSettings = () => { } style={{ flex: 2 }} /> - {pendingDeleteIndex === index ? ( - <> - - - - ) : ( - - )} +
diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index 2827692f0..e69de29bb 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -1,19 +0,0 @@ -.relationship-node { - background-color: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - padding: 6px 10px; - font-weight: 500; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - min-width: 100px; - text-align: center; -} - -.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/apps/obsidian/styles.css b/apps/obsidian/styles.css index 2827692f0..007500fb2 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -1,14 +1,3 @@ -.relationship-node { - background-color: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - padding: 6px 10px; - font-weight: 500; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - min-width: 100px; - text-align: center; -} - .discourse-relations .relationship-visualization { transition: all 0.2s ease; }