Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 6 additions & 54 deletions apps/obsidian/src/components/NodeTypeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { App, Editor, SuggestModal, TFile, Notice } from "obsidian";
import { DiscourseNode } from "~/types";
import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression";
import { checkInvalidChars } from "~/utils/validateNodeType";
import { processTextToDiscourseNode } from "~/utils/createNodeFromSelectedText";

export class NodeTypeModal extends SuggestModal<DiscourseNode> {
constructor(
Expand All @@ -27,58 +26,11 @@ export class NodeTypeModal extends SuggestModal<DiscourseNode> {
el.createEl("div", { text: nodeType.name });
}

async createDiscourseNode(
title: string,
nodeType: DiscourseNode,
): Promise<TFile | null> {
try {
const instanceId = `${nodeType.id}-${Date.now()}`;
const filename = `${title}.md`;

await this.app.vault.create(filename, "");

const newFile = this.app.vault.getAbstractFileByPath(filename);
if (!(newFile instanceof TFile)) {
throw new Error("Failed to create new file");
}

await this.app.fileManager.processFrontMatter(newFile, (fm) => {
fm.nodeTypeId = nodeType.id;
fm.nodeInstanceId = instanceId;
});

new Notice(`Created discourse node: ${title}`);
return newFile;
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
new Notice(`Error creating discourse node: ${errorMessage}`, 5000);
console.error("Failed to create discourse node:", error);
return null;
}
}

async 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, "");

const isFilenameValid = checkInvalidChars(formattedNodeName);
if (!isFilenameValid.isValid) {
new Notice(`${isFilenameValid.error}`, 5000);
return;
}

const newFile = await this.createDiscourseNode(formattedNodeName, nodeType);
if (newFile) {
this.editor.replaceSelection(`[[${formattedNodeName}]]`);
}
await processTextToDiscourseNode({
app: this.app,
editor: this.editor,
nodeType,
});
}
}
35 changes: 33 additions & 2 deletions apps/obsidian/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Plugin } from "obsidian";
import { Plugin, Editor, Menu, Notice, TFile } from "obsidian";
import { SettingsTab } from "~/components/Settings";
import { Settings } from "~/types";
import { registerCommands } from "~/utils/registerCommands";
import { DiscourseContextView } from "~/components/DiscourseContextView";
import { VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types";
import { VIEW_TYPE_DISCOURSE_CONTEXT, DiscourseNode } from "~/types";
import { processTextToDiscourseNode } from "./utils/createNodeFromSelectedText";

const DEFAULT_SETTINGS: Settings = {
nodeTypes: [],
Expand All @@ -27,6 +28,36 @@ export default class DiscourseGraphPlugin extends Plugin {
this.addRibbonIcon("telescope", "Toggle Discourse Context", () => {
this.toggleDiscourseContextView();
});

this.registerEvent(
this.app.workspace.on("editor-menu", (menu: Menu, editor: Editor) => {
if (!editor.getSelection()) return;

menu.addItem((menuItem) => {
menuItem.setTitle("Turn into Discourse Node");
menuItem.setIcon("file-type");

// Create submenu using the unofficial API pattern
// @ts-ignore - setSubmenu is not officially in the API but works
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this be supported? What is obsidian's stance on editing the submenu?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i couldn't find any official documentation to create submenu but followed this forum discussion:
https://forum.obsidian.md/t/submenu-items-within-the-editor-context-menu/39699

const submenu = menuItem.setSubmenu();

this.settings.nodeTypes.forEach((nodeType) => {
submenu.addItem((item: any) => {
item
.setTitle(nodeType.name)
.setIcon("file-type")
.onClick(async () => {
await processTextToDiscourseNode({
app: this.app,
editor,
nodeType,
});
});
});
});
});
}),
);
}

toggleDiscourseContextView() {
Expand Down
102 changes: 102 additions & 0 deletions apps/obsidian/src/utils/createNodeFromSelectedText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { App, Editor, Notice, TFile } from "obsidian";
import { DiscourseNode } from "~/types";
import { getDiscourseNodeFormatExpression } from "./getDiscourseNodeFormatExpression";
import { checkInvalidChars } from "./validateNodeType";

export const formatNodeName = (
selectedText: string,
nodeType: DiscourseNode,
): string | null => {
const regex = getDiscourseNodeFormatExpression(nodeType.format);
const nodeFormat = regex.source.match(/^\^(.*?)\(\.\*\?\)(.*?)\$$/);

if (!nodeFormat) return null;

return (
nodeFormat[1]?.replace(/\\/g, "") +
selectedText +
nodeFormat[2]?.replace(/\\/g, "")
);
};

export const createDiscourseNodeFile = async ({
app,
formattedNodeName,
nodeType,
}: {
app: App;
formattedNodeName: string;
nodeType: DiscourseNode;
}): Promise<TFile | null> => {
try {
const existingFile = app.vault.getAbstractFileByPath(
`${formattedNodeName}.md`,
);
if (existingFile && existingFile instanceof TFile) {
new Notice(`File ${formattedNodeName} already exists`, 3000);
return existingFile;
}

const newFile = await app.vault.create(`${formattedNodeName}.md`, "");
await app.fileManager.processFrontMatter(newFile, (fm) => {
fm.nodeTypeId = nodeType.id;
});

const notice = new DocumentFragment();
const spanEl = notice.createEl("span", {
text: "Created discourse node: ",
});

const linkEl = spanEl.createEl("a", {
text: formattedNodeName,
cls: "clickable-link",
});
linkEl.style.textDecoration = "underline";
linkEl.style.cursor = "pointer";
linkEl.addEventListener("click", () => {
app.workspace.openLinkText(formattedNodeName, "", false);
});

new Notice(notice, 4000);

return newFile;
} catch (error) {
console.error("Error creating discourse node:", error);
new Notice(
`Error creating node: ${error instanceof Error ? error.message : String(error)}`,
5000,
);
return null;
}
};

export const processTextToDiscourseNode = async ({
app,
editor,
nodeType,
}: {
app: App;
editor: Editor;
nodeType: DiscourseNode;
}): Promise<TFile | null> => {
const selectedText = editor.getSelection();
const formattedNodeName = formatNodeName(selectedText, nodeType);
if (!formattedNodeName) return null;

const isFilenameValid = checkInvalidChars(formattedNodeName);
if (!isFilenameValid.isValid) {
new Notice(`${isFilenameValid.error}`, 5000);
return null;
}

const newFile = await createDiscourseNodeFile({
app,
formattedNodeName,
nodeType,
});
if (newFile) {
editor.replaceSelection(`[[${formattedNodeName}]]`);
}

return newFile;
};