From dd946ecc8e465dedc35a401261251846a2011926 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 25 Jun 2025 15:57:54 -0400 Subject: [PATCH 1/3] convert page to node --- .../src/components/CreateNodeModal.tsx | 24 ++++++-- apps/obsidian/src/index.ts | 55 ++++++++++++++++++- apps/obsidian/src/utils/createNode.ts | 41 ++++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/apps/obsidian/src/components/CreateNodeModal.tsx b/apps/obsidian/src/components/CreateNodeModal.tsx index 641275f91..c196cad4d 100644 --- a/apps/obsidian/src/components/CreateNodeModal.tsx +++ b/apps/obsidian/src/components/CreateNodeModal.tsx @@ -9,6 +9,8 @@ type CreateNodeFormProps = { plugin: DiscourseGraphPlugin; onNodeCreate: (nodeType: DiscourseNode, title: string) => Promise; onCancel: () => void; + initialTitle?: string; + initialNodeType?: DiscourseNode; }; export function CreateNodeForm({ @@ -16,10 +18,12 @@ export function CreateNodeForm({ plugin, onNodeCreate, onCancel, + initialTitle = "", + initialNodeType, }: CreateNodeFormProps) { - const [title, setTitle] = useState(""); + const [title, setTitle] = useState(initialTitle); const [selectedNodeType, setSelectedNodeType] = - useState(null); + useState(initialNodeType || null); const [isSubmitting, setIsSubmitting] = useState(false); const titleInputRef = useRef(null); @@ -30,10 +34,10 @@ export function CreateNodeForm({ }, 50); }, []); - const isFormValid = title.trim() && selectedNodeType; + const isFormValid = title.trim() !== "" && selectedNodeType !== null; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { + if (e.key === "Enter" && !e.shiftKey && isFormValid && !isSubmitting) { e.preventDefault(); handleConfirm(); } else if (e.key === "Escape") { @@ -43,8 +47,8 @@ export function CreateNodeForm({ }; const handleNodeTypeChange = (e: React.ChangeEvent) => { - const selectedId = e.target.value; - setSelectedNodeType(nodeTypes.find((nt) => nt.id === selectedId) || null); + const nodeType = nodeTypes.find((type) => type.id === e.target.value); + setSelectedNodeType(nodeType || null); }; const handleConfirm = async () => { @@ -151,6 +155,8 @@ type CreateNodeModalProps = { nodeTypes: DiscourseNode[]; plugin: DiscourseGraphPlugin; onNodeCreate: (nodeType: DiscourseNode, title: string) => Promise; + initialTitle?: string; + initialNodeType?: DiscourseNode; }; export class CreateNodeModal extends Modal { @@ -161,12 +167,16 @@ export class CreateNodeModal extends Modal { title: string, ) => Promise; private root: Root | null = null; + private initialTitle?: string; + private initialNodeType?: DiscourseNode; constructor(app: App, props: CreateNodeModalProps) { super(app); this.nodeTypes = props.nodeTypes; this.plugin = props.plugin; this.onNodeCreate = props.onNodeCreate; + this.initialTitle = props.initialTitle; + this.initialNodeType = props.initialNodeType; } onOpen() { @@ -181,6 +191,8 @@ export class CreateNodeModal extends Modal { plugin={this.plugin} onNodeCreate={this.onNodeCreate} onCancel={() => this.close()} + initialTitle={this.initialTitle} + initialNodeType={this.initialNodeType} /> , ); diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 74954f46a..f1018252e 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -1,11 +1,15 @@ -import { Plugin, Editor, Menu } from "obsidian"; +import { Plugin, Editor, Menu, TFile, Events } 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 { createDiscourseNode } from "~/utils/createNode"; +import { + convertToDiscourseNode, + createDiscourseNode, +} from "~/utils/createNode"; import { DEFAULT_SETTINGS } from "~/constants"; +import { CreateNodeModal } from "~/components/CreateNodeModal"; export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; @@ -28,6 +32,53 @@ export default class DiscourseGraphPlugin extends Plugin { // Initialize frontmatter CSS this.updateFrontmatterStyles(); + this.registerEvent( + // @ts-ignore - file-menu event exists but is not in the type definitions + this.app.workspace.on("file-menu", (menu: Menu, file: TFile) => { + const fileCache = this.app.metadataCache.getFileCache(file); + const fileNodeType = fileCache?.frontmatter?.nodeTypeId; + + if ( + !fileNodeType || + !this.settings.nodeTypes.some( + (nodeType) => nodeType.id === fileNodeType, + ) + ) { + menu.addItem((menuItem) => { + menuItem.setTitle("Convert into"); + menuItem.setIcon("file-type"); + + // Create submenu using the unofficial API pattern + // @ts-ignore - setSubmenu is not officially in the API but works + const submenu = menuItem.setSubmenu(); + + this.settings.nodeTypes.forEach((nodeType) => { + submenu.addItem((item: any) => { + item + .setTitle(nodeType.name) + .setIcon("file-type") + .onClick(() => { + new CreateNodeModal(this.app, { + nodeTypes: this.settings.nodeTypes, + plugin: this, + initialTitle: file.basename, + initialNodeType: nodeType, + onNodeCreate: async (nodeType, title) => { + await convertToDiscourseNode({ + plugin: this, + file, + nodeType, + }); + }, + }).open(); + }); + }); + }); + }); + } + }), + ); + this.registerEvent( this.app.workspace.on("editor-menu", (menu: Menu, editor: Editor) => { if (!editor.getSelection()) return; diff --git a/apps/obsidian/src/utils/createNode.ts b/apps/obsidian/src/utils/createNode.ts index fcc9f9543..b680391dd 100644 --- a/apps/obsidian/src/utils/createNode.ts +++ b/apps/obsidian/src/utils/createNode.ts @@ -139,3 +139,44 @@ export const createDiscourseNode = async ({ return newFile; }; + +export const convertToDiscourseNode = async ({ + plugin, + file, + nodeType, +}: { + plugin: DiscourseGraphPlugin; + file: TFile; + nodeType: DiscourseNode; +}): Promise => { + try { + const formattedNodeName = formatNodeName(file.basename, nodeType); + if (!formattedNodeName) { + new Notice("Failed to format node name", 3000); + return; + } + + const isFilenameValid = checkInvalidChars(formattedNodeName); + if (!isFilenameValid.isValid) { + new Notice(`${isFilenameValid.error}`, 5000); + return; + } + + await plugin.app.fileManager.processFrontMatter(file, (fm) => { + fm.nodeTypeId = nodeType.id; + }); + + if (formattedNodeName !== file.basename) { + const newPath = file.path.replace(file.basename, formattedNodeName); + await plugin.app.fileManager.renameFile(file, newPath); + } + + new Notice("Converted page to discourse node", 10000); + } catch (error) { + console.error("Error converting to discourse node:", error); + new Notice( + `Error converting to discourse node: ${error instanceof Error ? error.message : String(error)}`, + 5000, + ); + } +}; From 5c906e8506c325d2cadbab16352cf67fa73c380b Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 25 Jun 2025 16:16:00 -0400 Subject: [PATCH 2/3] sma styling --- apps/obsidian/src/components/CreateNodeModal.tsx | 6 +++--- apps/obsidian/src/index.ts | 5 ++--- apps/obsidian/src/utils/createNode.ts | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/components/CreateNodeModal.tsx b/apps/obsidian/src/components/CreateNodeModal.tsx index c196cad4d..880e48beb 100644 --- a/apps/obsidian/src/components/CreateNodeModal.tsx +++ b/apps/obsidian/src/components/CreateNodeModal.tsx @@ -34,7 +34,7 @@ export function CreateNodeForm({ }, 50); }, []); - const isFormValid = title.trim() !== "" && selectedNodeType !== null; + const isFormValid = title.trim() && selectedNodeType; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey && isFormValid && !isSubmitting) { @@ -47,8 +47,8 @@ export function CreateNodeForm({ }; const handleNodeTypeChange = (e: React.ChangeEvent) => { - const nodeType = nodeTypes.find((type) => type.id === e.target.value); - setSelectedNodeType(nodeType || null); + const selectedId = e.target.value; + setSelectedNodeType(nodeTypes.find((nt) => nt.id === selectedId) || null); }; const handleConfirm = async () => { diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index f1018252e..e45779f8f 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -5,7 +5,7 @@ import { registerCommands } from "~/utils/registerCommands"; import { DiscourseContextView } from "~/components/DiscourseContextView"; import { VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types"; import { - convertToDiscourseNode, + convertPageToDiscourseNode, createDiscourseNode, } from "~/utils/createNode"; import { DEFAULT_SETTINGS } from "~/constants"; @@ -48,7 +48,6 @@ export default class DiscourseGraphPlugin extends Plugin { menuItem.setTitle("Convert into"); menuItem.setIcon("file-type"); - // Create submenu using the unofficial API pattern // @ts-ignore - setSubmenu is not officially in the API but works const submenu = menuItem.setSubmenu(); @@ -64,7 +63,7 @@ export default class DiscourseGraphPlugin extends Plugin { initialTitle: file.basename, initialNodeType: nodeType, onNodeCreate: async (nodeType, title) => { - await convertToDiscourseNode({ + await convertPageToDiscourseNode({ plugin: this, file, nodeType, diff --git a/apps/obsidian/src/utils/createNode.ts b/apps/obsidian/src/utils/createNode.ts index b680391dd..869a2949e 100644 --- a/apps/obsidian/src/utils/createNode.ts +++ b/apps/obsidian/src/utils/createNode.ts @@ -140,7 +140,7 @@ export const createDiscourseNode = async ({ return newFile; }; -export const convertToDiscourseNode = async ({ +export const convertPageToDiscourseNode = async ({ plugin, file, nodeType, From f8cf73a6131ee49fbe5916e325a358a18cdcd9cd Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 25 Jun 2025 16:31:06 -0400 Subject: [PATCH 3/3] address PR comments --- apps/obsidian/src/utils/createNode.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/obsidian/src/utils/createNode.ts b/apps/obsidian/src/utils/createNode.ts index 869a2949e..a78bc3db2 100644 --- a/apps/obsidian/src/utils/createNode.ts +++ b/apps/obsidian/src/utils/createNode.ts @@ -166,10 +166,12 @@ export const convertPageToDiscourseNode = async ({ fm.nodeTypeId = nodeType.id; }); - if (formattedNodeName !== file.basename) { - const newPath = file.path.replace(file.basename, formattedNodeName); + const dirPath = file.parent?.path ?? ""; + const newPath = dirPath + ? `${dirPath}/${formattedNodeName}.md` + : `${formattedNodeName}.md`; await plugin.app.fileManager.renameFile(file, newPath); - } + new Notice("Converted page to discourse node", 10000); } catch (error) {