Skip to content
44 changes: 44 additions & 0 deletions apps/obsidian/src/components/NodeTypeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { App, Editor, SuggestModal } from "obsidian";
import { DiscourseNode } from "../types";
import { getDiscourseNodeFormatExpression } from "../utils/getDiscourseNodeFormatExpression";

export class NodeTypeModal extends SuggestModal<DiscourseNode> {
constructor(
app: App,
private editor: Editor,
private nodeTypes: DiscourseNode[],
) {
super(app);
}

getItemText(item: DiscourseNode): string {
return item.name;
}

getSuggestions() {
const query = this.inputEl.value.toLowerCase();
return this.nodeTypes.filter((node) =>
this.getItemText(node).toLowerCase().includes(query),
);
}

renderSuggestion(nodeType: DiscourseNode, el: HTMLElement) {
el.createEl("div", { text: nodeType.name });
}

onChooseSuggestion(nodeType: DiscourseNode) {
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}]]`);
}
}
209 changes: 183 additions & 26 deletions apps/obsidian/src/components/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,183 @@
import { StrictMode } from "react";
import { App, PluginSettingTab, Setting } from "obsidian";
import { StrictMode, useState, useEffect } 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 { validateNodeFormat } from "../utils/validateNodeFormat";

const Settings = () => {
const app = useApp();
if (!app) {
return <div>An error occured</div>;
}
const NodeTypeSettings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => {
const [nodeTypes, setNodeTypes] = useState(
() => plugin.settings.nodeTypes ?? [],
);
const [formatErrors, setFormatErrors] = useState<Record<number, string>>({});
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<void> => {
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);
};

return <h4>Settings for {app.vault.getName()}</h4>;
const handleDeleteNodeType = async (index: number): Promise<void> => {
const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index);
setNodeTypes(updatedNodeTypes);
plugin.settings.nodeTypes = updatedNodeTypes;
await plugin.saveSettings();
};

const handleSave = async (): Promise<void> => {
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 (
<div className="discourse-node-types">
<h3>Node Types</h3>
{nodeTypes.map((nodeType, index) => (
<div key={index} className="setting-item">
<div
style={{ display: "flex", flexDirection: "column", width: "100%" }}
>
<div style={{ display: "flex", gap: "10px" }}>
<input
type="text"
placeholder="Name"
value={nodeType.name}
onChange={(e) =>
handleNodeTypeChange(index, "name", e.target.value)
}
style={{ flex: 1 }}
/>
<input
type="text"
placeholder="Format (e.g., [CLM] - {content})"
value={nodeType.format}
onChange={(e) =>
handleNodeTypeChange(index, "format", e.target.value)
}
style={{ flex: 2 }}
/>
<button
onClick={() => handleDeleteNodeType(index)}
className="mod-warning"
>
Delete
</button>
</div>
{formatErrors[index] && (
<div
style={{
color: "var(--text-error)",
fontSize: "12px",
marginTop: "4px",
}}
>
{formatErrors[index]}
</div>
)}
</div>
</div>
))}
<div className="setting-item">
<div style={{ display: "flex", gap: "10px" }}>
<button onClick={handleAddNodeType}>Add Node Type</button>
<button
onClick={handleSave}
className={hasUnsavedChanges ? "mod-cta" : ""}
disabled={
!hasUnsavedChanges || Object.keys(formatErrors).length > 0
}
>
Save Changes
</button>
</div>
</div>
{hasUnsavedChanges && (
<div style={{ marginTop: "8px", color: "var(--text-muted)" }}>
You have unsaved changes
</div>
)}
</div>
);
};

const Settings = ({ plugin }: { plugin: DiscourseGraphPlugin }) => {
return (
<div>
<NodeTypeSettings plugin={plugin} />
</div>
);
};

export class SettingsTab extends PluginSettingTab {
Expand All @@ -25,31 +192,21 @@ export class SettingsTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();

// Example react component in settings
const settingsComponentEl = containerEl.createDiv();
this.root = createRoot(settingsComponentEl);
this.root.render(
<StrictMode>
<ContextProvider app={this.app}>
<Settings />
<Settings plugin={this.plugin} />
</ContextProvider>
</StrictMode>,
);
}

// 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();
}),
);
hide(): void {
if (this.root) {
this.root.unmount();
this.root = null;
}
}
}
}
11 changes: 4 additions & 7 deletions apps/obsidian/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { Plugin } from "obsidian";
import { registerCommands } from "~/utils/registerCommands";
import { SettingsTab } from "~/components/Settings";

type Settings = {
mySetting: string;
};
import { Settings } from "./types";
import { registerCommands } from "./utils/registerCommands";

const DEFAULT_SETTINGS: Settings = {
mySetting: "default",
nodeTypes: [],
};

export default class DiscourseGraphPlugin extends Plugin {
settings: Settings = { mySetting: "default" };
settings: Settings = { ...DEFAULT_SETTINGS };

async onload() {
await this.loadSettings();
Expand Down
10 changes: 10 additions & 0 deletions apps/obsidian/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type DiscourseNode = {
name: string;
format: string;
shortcut?: string;
color?: string;
};

export type Settings = {
nodeTypes: DiscourseNode[];
};
9 changes: 9 additions & 0 deletions apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const getDiscourseNodeFormatExpression = (format: string) =>
format
? new RegExp(
`^${format
.replace(/(\[|\]|\?|\.|\+)/g, "\\$1")
.replace(/{[a-zA-Z]+}/g, "(.*?)")}$`,
"s",
)
: /$^/;
20 changes: 19 additions & 1 deletion apps/obsidian/src/utils/registerCommands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
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 { NodeTypeModal } from "~/components/NodeTypeModal";

export const registerCommands = (plugin: DiscourseGraphPlugin) => {
// This adds a simple command that can be triggered anywhere
Expand All @@ -22,6 +30,7 @@ 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",
Expand All @@ -42,4 +51,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();
},
});
};
28 changes: 28 additions & 0 deletions apps/obsidian/src/utils/validateNodeFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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 ]]",
};
}

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 };
}