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
18 changes: 15 additions & 3 deletions apps/obsidian/src/components/CreateNodeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,21 @@ type CreateNodeFormProps = {
plugin: DiscourseGraphPlugin;
onNodeCreate: (nodeType: DiscourseNode, title: string) => Promise<void>;
onCancel: () => void;
initialTitle?: string;
initialNodeType?: DiscourseNode;
};

export function CreateNodeForm({
nodeTypes,
plugin,
onNodeCreate,
onCancel,
initialTitle = "",
initialNodeType,
}: CreateNodeFormProps) {
const [title, setTitle] = useState("");
const [title, setTitle] = useState(initialTitle);
const [selectedNodeType, setSelectedNodeType] =
useState<DiscourseNode | null>(null);
useState<DiscourseNode | null>(initialNodeType || null);
const [isSubmitting, setIsSubmitting] = useState(false);
const titleInputRef = useRef<HTMLInputElement>(null);

Expand All @@ -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") {
Expand Down Expand Up @@ -151,6 +155,8 @@ type CreateNodeModalProps = {
nodeTypes: DiscourseNode[];
plugin: DiscourseGraphPlugin;
onNodeCreate: (nodeType: DiscourseNode, title: string) => Promise<void>;
initialTitle?: string;
initialNodeType?: DiscourseNode;
};

export class CreateNodeModal extends Modal {
Expand All @@ -161,12 +167,16 @@ export class CreateNodeModal extends Modal {
title: string,
) => Promise<void>;
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() {
Expand All @@ -181,6 +191,8 @@ export class CreateNodeModal extends Modal {
plugin={this.plugin}
onNodeCreate={this.onNodeCreate}
onCancel={() => this.close()}
initialTitle={this.initialTitle}
initialNodeType={this.initialNodeType}
/>
</StrictMode>,
);
Expand Down
54 changes: 52 additions & 2 deletions apps/obsidian/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions apps/obsidian/src/utils/createNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,46 @@ export const createDiscourseNode = async ({

return newFile;
};

export const convertPageToDiscourseNode = async ({
plugin,
file,
nodeType,
}: {
plugin: DiscourseGraphPlugin;
file: TFile;
nodeType: DiscourseNode;
}): Promise<void> => {
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,
);
}
};