diff --git a/apps/obsidian/src/components/CreateNodeModal.tsx b/apps/obsidian/src/components/CreateNodeModal.tsx index 641275f91..880e48beb 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); @@ -33,7 +37,7 @@ export function CreateNodeForm({ const isFormValid = title.trim() && selectedNodeType; 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") { @@ -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..e45779f8f 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 { + convertPageToDiscourseNode, + 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,52 @@ 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"); + + // @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 convertPageToDiscourseNode({ + 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..a78bc3db2 100644 --- a/apps/obsidian/src/utils/createNode.ts +++ b/apps/obsidian/src/utils/createNode.ts @@ -139,3 +139,46 @@ export const createDiscourseNode = async ({ return newFile; }; + +export const convertPageToDiscourseNode = 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; + }); + + 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) { + console.error("Error converting to discourse node:", error); + new Notice( + `Error converting to discourse node: ${error instanceof Error ? error.message : String(error)}`, + 5000, + ); + } +};