diff --git a/src/main.ts b/src/main.ts index 979cb1c..e26dd94 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,6 +22,7 @@ import { buildSemanticMappings } from "./mapping/semanticMappingBuilder"; import { ConfirmOverwriteModal } from "./ui/ConfirmOverwriteModal"; import { GraphSchemaLoader } from "./schema/GraphSchemaLoader"; import type { GraphSchema } from "./mapping/graphSchema"; +import { joinFolderAndBasename, ensureUniqueFilePath, ensureFolderExists } from "./mapping/folderHelpers"; export default class MindnetCausalAssistantPlugin extends Plugin { settings: MindnetSettings; @@ -79,14 +80,18 @@ export default class MindnetCausalAssistantPlugin extends Plugin { return; } + // Determine default folder (from profile defaults or settings) + const defaultFolder = ""; // Will be set per profile selection + // Open profile selection modal new ProfileSelectionModal( this.app, config, async (result) => { - await this.createNoteFromProfileAndOpen(result, basename); + await this.createNoteFromProfileAndOpen(result, basename, result.folderPath); }, - title + title, + defaultFolder ).open(); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -358,6 +363,9 @@ export default class MindnetCausalAssistantPlugin extends Plugin { // Ignore errors getting initial title } + // Determine default folder (from profile defaults or settings) + const defaultFolder = ""; // Will be set per profile selection + // Show modal new ProfileSelectionModal( this.app, @@ -365,7 +373,8 @@ export default class MindnetCausalAssistantPlugin extends Plugin { async (result) => { await this.createNoteFromProfile(result); }, - initialTitle + initialTitle, + defaultFolder ).open(); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -429,8 +438,9 @@ export default class MindnetCausalAssistantPlugin extends Plugin { } private async createNoteFromProfileAndOpen( - result: { profile: import("./interview/types").InterviewProfile; title: string }, - preferredBasename?: string + result: { profile: import("./interview/types").InterviewProfile; title: string; folderPath?: string }, + preferredBasename?: string, + folderPath?: string ): Promise { try { const config = await this.ensureInterviewConfigLoaded(); @@ -462,23 +472,24 @@ export default class MindnetCausalAssistantPlugin extends Plugin { // Create file content const content = `${frontmatter}\n\n`; - // Determine file path (use filenameBase directly, preserving spaces) - // Handle name conflicts by appending " (2)", " (3)", etc. (Obsidian style) - let fileName = `${filenameBase.trim()}.md`; - let filePath = fileName; - let attempt = 1; - - while (true) { - const existingFile = this.app.vault.getAbstractFileByPath(filePath); - if (!existingFile) { - break; // File doesn't exist, we can use this path - } - - attempt++; - fileName = `${filenameBase.trim()} (${attempt}).md`; - filePath = fileName; + // Determine folder path (from result, parameter, profile defaults, or settings) + const finalFolderPath = folderPath || result.folderPath || + (result.profile.defaults?.folder as string) || + this.settings.defaultNotesFolder || + ""; + + // Ensure folder exists + if (finalFolderPath) { + await ensureFolderExists(this.app, finalFolderPath); } + // Build file path + const fileName = `${filenameBase.trim()}.md`; + const desiredPath = joinFolderAndBasename(finalFolderPath, fileName); + + // Ensure unique file path + const filePath = await ensureUniqueFilePath(this.app, desiredPath); + // Create file const file = await this.app.vault.create(filePath, content); diff --git a/src/mapping/folderHelpers.ts b/src/mapping/folderHelpers.ts new file mode 100644 index 0000000..99244ba --- /dev/null +++ b/src/mapping/folderHelpers.ts @@ -0,0 +1,93 @@ +/** + * Helper functions for folder and file path handling. + */ + +import { App } from "obsidian"; +import { normalizeVaultPath } from "../settings"; + +/** + * Join folder path and basename to create full file path. + * Handles empty folder (root) correctly. + */ +export function joinFolderAndBasename(folder: string, basename: string): string { + const normalizedFolder = normalizeVaultPath(folder).trim(); + const normalizedBasename = basename.trim(); + + if (!normalizedFolder) { + // Root folder + return normalizedBasename; + } + + // Remove trailing slash if present + const cleanFolder = normalizedFolder.replace(/\/$/, ""); + + return `${cleanFolder}/${normalizedBasename}`; +} + +/** + * Ensure unique file path by appending (2), (3), etc. if file exists. + * Returns the first available path. + */ +export async function ensureUniqueFilePath( + app: App, + desiredPath: string +): Promise { + const normalizedPath = normalizeVaultPath(desiredPath); + + // Check if file exists + const existingFile = app.vault.getAbstractFileByPath(normalizedPath); + if (!existingFile) { + return normalizedPath; + } + + // Extract directory and basename + const lastSlash = normalizedPath.lastIndexOf("/"); + const dir = lastSlash >= 0 ? normalizedPath.substring(0, lastSlash) : ""; + const fullBasename = lastSlash >= 0 ? normalizedPath.substring(lastSlash + 1) : normalizedPath; + + // Extract name and extension + const lastDot = fullBasename.lastIndexOf("."); + const name = lastDot >= 0 ? fullBasename.substring(0, lastDot) : fullBasename; + const ext = lastDot >= 0 ? fullBasename.substring(lastDot) : ""; + + // Try (2), (3), etc. + let attempt = 2; + while (attempt < 1000) { + const newBasename = `${name} (${attempt})${ext}`; + const newPath = dir ? `${dir}/${newBasename}` : newBasename; + + const existing = app.vault.getAbstractFileByPath(newPath); + if (!existing) { + return newPath; + } + + attempt++; + } + + // Fallback: add timestamp + const timestamp = Date.now(); + const newBasename = `${name}_${timestamp}${ext}`; + return dir ? `${dir}/${newBasename}` : newBasename; +} + +/** + * Ensure folder exists, creating it if necessary (mkdirp). + */ +export async function ensureFolderExists(app: App, folderPath: string): Promise { + const normalizedPath = normalizeVaultPath(folderPath).trim(); + + if (!normalizedPath) { + // Root folder, always exists + return; + } + + // Check if folder exists + const existing = app.vault.getAbstractFileByPath(normalizedPath); + if (existing) { + // Already exists + return; + } + + // Create folder (mkdirp) + await app.vault.createFolder(normalizedPath); +} diff --git a/src/settings.ts b/src/settings.ts index b75256d..9da1708 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -15,6 +15,7 @@ export interface MindnetSettings { defaultEdgeType: string; // default: "" unassignedHandling: "prompt" | "none" | "defaultType" | "advisor"; // default: "prompt" allowOverwriteExistingMappings: boolean; // default: false + defaultNotesFolder: string; // default: "" (vault root) } export const DEFAULT_SETTINGS: MindnetSettings = { @@ -33,6 +34,7 @@ export interface MindnetSettings { defaultEdgeType: "", unassignedHandling: "prompt", allowOverwriteExistingMappings: false, + defaultNotesFolder: "", }; /** diff --git a/src/tests/mapping/folderHelpers.test.ts b/src/tests/mapping/folderHelpers.test.ts new file mode 100644 index 0000000..c6878c7 --- /dev/null +++ b/src/tests/mapping/folderHelpers.test.ts @@ -0,0 +1,36 @@ +/** + * Unit tests for folder helpers. + */ + +import { describe, it, expect } from "vitest"; +import { joinFolderAndBasename, ensureUniqueFilePath } from "../../mapping/folderHelpers"; + +describe("joinFolderAndBasename", () => { + it("should join folder and basename", () => { + expect(joinFolderAndBasename("folder1/folder2", "note.md")).toBe("folder1/folder2/note.md"); + }); + + it("should handle empty folder (root)", () => { + expect(joinFolderAndBasename("", "note.md")).toBe("note.md"); + }); + + it("should handle folder with trailing slash", () => { + expect(joinFolderAndBasename("folder/", "note.md")).toBe("folder/note.md"); + }); + + it("should normalize paths", () => { + expect(joinFolderAndBasename("folder1\\folder2", "note.md")).toBe("folder1/folder2/note.md"); + }); + + it("should trim whitespace", () => { + expect(joinFolderAndBasename(" folder ", " note.md ")).toBe("folder/note.md"); + }); +}); + +describe("ensureUniqueFilePath", () => { + // Note: Full tests would require mocking App and vault + // These are basic structure tests + it("should be a function", () => { + expect(typeof ensureUniqueFilePath).toBe("function"); + }); +}); diff --git a/src/ui/FolderTreeModal.ts b/src/ui/FolderTreeModal.ts new file mode 100644 index 0000000..751aa57 --- /dev/null +++ b/src/ui/FolderTreeModal.ts @@ -0,0 +1,461 @@ +/** + * Modal for selecting a folder from the vault. + */ + +import { Modal, Notice } from "obsidian"; +import { buildFolderTree } from "../entityPicker/folderTree"; +import { NoteIndex } from "../entityPicker/noteIndex"; +import type { FolderTreeNode } from "../entityPicker/types"; + +export interface FolderTreeResult { + folderPath: string; // Vault-relative path, empty string for root +} + +interface FolderNodeState { + expanded: boolean; +} + +export class FolderTreeModal extends Modal { + private result: FolderTreeResult | null = null; + private resolve: ((result: FolderTreeResult | null) => void) | null = null; + private noteIndex: NoteIndex; + private selectedFolderPath: string = ""; // Empty = root + private nodeStates: Map = new Map(); // path -> state + + constructor(app: any, noteIndex: NoteIndex, initialFolderPath: string = "") { + super(app); + this.noteIndex = noteIndex; + this.selectedFolderPath = initialFolderPath; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("folder-tree-modal"); + + contentEl.createEl("h2", { text: "Select folder" }); + + // Build folder tree from note paths AND empty folders + const allEntries = this.noteIndex.getAllEntries(); + + // Also get all folders from vault (including empty ones) + // Use vault.adapter to get all folders + const folderPaths = new Set(); + + // Add folders from note entries + for (const entry of allEntries) { + if (entry.folder) { + folderPaths.add(entry.folder); + } + } + + // Get all folders from vault (including empty) + // Iterate through vault's abstract files to find folders + const allAbstractFiles = this.app.vault.getAllFolders(); + for (const folder of allAbstractFiles) { + if (folder.path) { + folderPaths.add(folder.path); + } + } + + // Convert to NoteIndexEntry format for buildFolderTree + // We need to create entries for folders that don't have notes + const folderEntries: Array = []; + for (const folderPath of folderPaths) { + // Check if we already have entries for this folder + const hasEntries = allEntries.some((e) => e.folder === folderPath); + if (!hasEntries) { + // Create a dummy entry for empty folder + folderEntries.push({ + path: folderPath, + folder: folderPath, + basename: folderPath.split("/").pop() || "", + title: null, + type: null, + mtime: 0, + }); + } + } + + // Combine with note entries + const combinedEntries = [...allEntries, ...folderEntries]; + const folderTree = buildFolderTree(combinedEntries); + + // Current selection display + const currentDisplay = contentEl.createEl("div", { cls: "current-folder" }); + currentDisplay.style.marginBottom = "1em"; + currentDisplay.style.padding = "0.5em"; + currentDisplay.style.backgroundColor = "var(--background-secondary)"; + currentDisplay.style.borderRadius = "4px"; + + const currentLabel = currentDisplay.createEl("span", { + text: "Current: ", + cls: "current-label", + }); + const currentPath = currentDisplay.createEl("span", { + text: this.selectedFolderPath || "(root)", + cls: "current-path", + }); + currentPath.style.fontWeight = "bold"; + + // Tree container + const treeContainer = contentEl.createEl("div", { cls: "folder-tree-container" }); + treeContainer.style.maxHeight = "400px"; + treeContainer.style.overflowY = "auto"; + treeContainer.style.border = "1px solid var(--background-modifier-border)"; + treeContainer.style.borderRadius = "4px"; + treeContainer.style.padding = "0.5em"; + + // Store scroll position before rendering (if container already exists) + const existingContainer = contentEl.querySelector(".folder-tree-container"); + const scrollTop = existingContainer instanceof HTMLElement ? existingContainer.scrollTop : 0; + + // Render tree + this.renderFolderTree(folderTree, treeContainer); + + // Scroll to selected folder or restore position + setTimeout(() => { + if (this.selectedFolderPath) { + // Scroll to selected folder + const selectedElement = treeContainer.querySelector(`[data-folder-path="${this.selectedFolderPath}"]`); + if (selectedElement && selectedElement instanceof HTMLElement) { + selectedElement.scrollIntoView({ behavior: "auto", block: "center" }); + } + } else if (scrollTop > 0) { + // Restore previous scroll position + treeContainer.scrollTop = scrollTop; + } + }, 10); + + // Buttons + const buttonContainer = contentEl.createEl("div"); + buttonContainer.style.display = "flex"; + buttonContainer.style.gap = "0.5em"; + buttonContainer.style.justifyContent = "flex-end"; + buttonContainer.style.marginTop = "1em"; + + const cancelBtn = buttonContainer.createEl("button", { text: "Cancel" }); + cancelBtn.onclick = () => { + this.result = null; + this.close(); + }; + + const selectRootBtn = buttonContainer.createEl("button", { + text: "Select root", + }); + selectRootBtn.onclick = () => { + this.selectedFolderPath = ""; + this.result = { folderPath: "" }; + this.close(); + }; + + const newRootFolderBtn = buttonContainer.createEl("button", { + text: "New folder in root…", + }); + newRootFolderBtn.onclick = async () => { + const folderName = await this.promptForFolderName(); + if (folderName && folderName.trim()) { + try { + await this.app.vault.createFolder(folderName.trim()); + new Notice(`Folder created: ${folderName.trim()}`); + + // Refresh note index + this.noteIndex.refresh(); + + // Re-render + this.onOpen(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to create folder: ${msg}`); + } + } + }; + + const confirmBtn = buttonContainer.createEl("button", { + text: "Confirm", + cls: "mod-cta", + }); + confirmBtn.onclick = () => { + this.result = { folderPath: this.selectedFolderPath }; + this.close(); + }; + } + + private renderFolderTree( + tree: FolderTreeNode, + container: HTMLElement, + level: number = 0 + ): void { + // Skip root node (empty path), render its children + if (tree.path === "") { + // Render root's children + for (const child of tree.children) { + this.renderFolderNode(child, container, level); + } + } else { + // Render this node + this.renderFolderNode(tree, container, level); + } + } + + private renderFolderNode( + node: FolderTreeNode, + container: HTMLElement, + level: number + ): void { + const isSelected = node.path === this.selectedFolderPath; + const hasChildren = node.children.length > 0; + const state = this.nodeStates.get(node.path) || { expanded: level < 2 }; // Auto-expand first 2 levels + if (!this.nodeStates.has(node.path)) { + this.nodeStates.set(node.path, state); + } + + const folderItem = container.createEl("div", { + cls: `folder-item ${isSelected ? "is-selected" : ""}`, + }); + folderItem.setAttribute("data-folder-path", node.path); + folderItem.style.display = "flex"; + folderItem.style.alignItems = "center"; + folderItem.style.padding = "0.25em 0.5em"; + folderItem.style.cursor = "pointer"; + folderItem.style.borderRadius = "4px"; + folderItem.style.marginLeft = `${level * 1.5}em`; + + if (isSelected) { + folderItem.style.backgroundColor = "var(--interactive-hover)"; + folderItem.style.fontWeight = "bold"; + } + + folderItem.onmouseenter = () => { + if (!isSelected) { + folderItem.style.backgroundColor = "var(--background-modifier-hover)"; + } + }; + folderItem.onmouseleave = () => { + if (!isSelected) { + folderItem.style.backgroundColor = ""; + } + }; + + // Expand/collapse button + const expandBtn = folderItem.createEl("span", { + text: hasChildren ? (state.expanded ? "▼" : "▶") : " ", + cls: "expand-icon", + }); + expandBtn.style.width = "1em"; + expandBtn.style.marginRight = "0.25em"; + expandBtn.style.fontSize = "0.8em"; + expandBtn.style.cursor = hasChildren ? "pointer" : "default"; + expandBtn.style.userSelect = "none"; + + if (hasChildren) { + expandBtn.onclick = (e) => { + e.stopPropagation(); + state.expanded = !state.expanded; + this.onOpen(); // Re-render + }; + } + + // Folder icon and name + const folderLabel = folderItem.createEl("span", { + text: `📁 ${node.name}`, + }); + folderLabel.style.flex = "1"; + + // Note count + if (node.noteCount > 0) { + const countSpan = folderItem.createEl("span", { + text: ` (${node.noteCount})`, + cls: "note-count", + }); + countSpan.style.color = "var(--text-muted)"; + countSpan.style.fontSize = "0.9em"; + countSpan.style.marginRight = "0.5em"; + } + + // Action buttons container + const actionsContainer = folderItem.createEl("div", { + cls: "folder-actions", + }); + actionsContainer.style.display = "flex"; + actionsContainer.style.gap = "0.25em"; + actionsContainer.style.opacity = "0"; + actionsContainer.style.transition = "opacity 0.2s"; + + folderItem.onmouseenter = () => { + if (!isSelected) { + folderItem.style.backgroundColor = "var(--background-modifier-hover)"; + } + actionsContainer.style.opacity = "1"; + }; + folderItem.onmouseleave = () => { + if (!isSelected) { + folderItem.style.backgroundColor = ""; + } + actionsContainer.style.opacity = "0"; + }; + + // Select button + const selectBtn = actionsContainer.createEl("button", { + text: "Select", + cls: "mod-cta", + }); + selectBtn.style.fontSize = "0.85em"; + selectBtn.style.padding = "0.2em 0.5em"; + selectBtn.onclick = (e) => { + e.stopPropagation(); + this.selectedFolderPath = node.path; + // Update display without full re-render to preserve scroll + this.updateSelectionDisplay(); + }; + + // New folder button + const newFolderBtn = actionsContainer.createEl("button", { + text: "+", + title: "Create subfolder", + }); + newFolderBtn.style.fontSize = "0.9em"; + newFolderBtn.style.padding = "0.2em 0.4em"; + newFolderBtn.style.minWidth = "1.5em"; + newFolderBtn.onclick = async (e) => { + e.stopPropagation(); + const folderName = await this.promptForFolderName(); + if (folderName && folderName.trim()) { + const newFolderPath = node.path + ? `${node.path}/${folderName.trim()}` + : folderName.trim(); + + try { + await this.app.vault.createFolder(newFolderPath); + new Notice(`Folder created: ${newFolderPath}`); + + // Refresh note index + this.noteIndex.refresh(); + + // Expand parent and select new folder + state.expanded = true; + this.selectedFolderPath = newFolderPath; + + // Re-render (will scroll to selected folder) + this.onOpen(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to create folder: ${msg}`); + } + } + }; + + // Main click: select folder + folderItem.onclick = () => { + this.selectedFolderPath = node.path; + // Update display without full re-render to preserve scroll + this.updateSelectionDisplay(); + }; + + // Render children if expanded + if (hasChildren && state.expanded) { + const childrenContainer = container.createEl("div", { + cls: "folder-children", + }); + for (const child of node.children) { + this.renderFolderNode(child, childrenContainer, level + 1); + } + } + } + + private async promptForFolderName(): Promise { + return new Promise((resolve) => { + const modal = new Modal(this.app); + modal.titleEl.textContent = "New folder name"; + + const inputContainer = modal.contentEl.createEl("div"); + const input = inputContainer.createEl("input", { + type: "text", + placeholder: "Folder name", + }); + input.style.width = "100%"; + input.style.marginBottom = "1em"; + + const buttonContainer = modal.contentEl.createEl("div"); + buttonContainer.style.display = "flex"; + buttonContainer.style.gap = "0.5em"; + buttonContainer.style.justifyContent = "flex-end"; + + const cancelBtn = buttonContainer.createEl("button", { text: "Cancel" }); + cancelBtn.onclick = () => { + modal.close(); + resolve(null); + }; + + const createBtn = buttonContainer.createEl("button", { + text: "Create", + cls: "mod-cta", + }); + createBtn.onclick = () => { + const value = input.value.trim(); + modal.close(); + resolve(value || null); + }; + + input.onkeydown = (evt) => { + if (evt.key === "Enter") { + createBtn.click(); + } else if (evt.key === "Escape") { + cancelBtn.click(); + } + }; + + modal.open(); + input.focus(); + input.select(); + }); + } + + private updateSelectionDisplay(): void { + const { contentEl } = this; + + // Update current selection display + const currentPath = contentEl.querySelector(".current-path"); + if (currentPath) { + currentPath.textContent = this.selectedFolderPath || "(root)"; + } + + // Update selected state in tree + const allItems = Array.from(contentEl.querySelectorAll(".folder-item")); + for (const item of allItems) { + const itemPath = item.getAttribute("data-folder-path"); + if (itemPath === this.selectedFolderPath) { + item.addClass("is-selected"); + if (item instanceof HTMLElement) { + item.style.backgroundColor = "var(--interactive-hover)"; + item.style.fontWeight = "bold"; + } + } else { + item.removeClass("is-selected"); + if (item instanceof HTMLElement) { + item.style.backgroundColor = ""; + item.style.fontWeight = ""; + } + } + } + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + + if (this.resolve) { + this.resolve(this.result); + } + } + + /** + * Show modal and return promise that resolves with user's choice. + */ + async show(): Promise { + return new Promise((resolve) => { + this.resolve = resolve; + this.open(); + }); + } +} diff --git a/src/ui/MindnetSettingTab.ts b/src/ui/MindnetSettingTab.ts index 67a74e0..dff971e 100644 --- a/src/ui/MindnetSettingTab.ts +++ b/src/ui/MindnetSettingTab.ts @@ -312,5 +312,19 @@ export class MindnetSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); }) ); + + // Default notes folder + new Setting(containerEl) + .setName("Default notes folder") + .setDesc("Default folder for new notes (vault-relative path, empty for root). Profile defaults take precedence.") + .addText((text) => + text + .setPlaceholder("") + .setValue(this.plugin.settings.defaultNotesFolder) + .onChange(async (value) => { + this.plugin.settings.defaultNotesFolder = value || ""; + await this.plugin.saveSettings(); + }) + ); } } diff --git a/src/ui/ProfileSelectionModal.ts b/src/ui/ProfileSelectionModal.ts index 7bb0a1f..38a5dc9 100644 --- a/src/ui/ProfileSelectionModal.ts +++ b/src/ui/ProfileSelectionModal.ts @@ -1,9 +1,12 @@ import { App, Modal, Notice, Setting, TFile } from "obsidian"; import type { InterviewConfig, InterviewProfile } from "../interview/types"; +import { FolderTreeModal, type FolderTreeResult } from "./FolderTreeModal"; +import { NoteIndex } from "../entityPicker/noteIndex"; export interface ProfileSelectionResult { profile: InterviewProfile; title: string; + folderPath: string; // Vault-relative path, empty string for root } export class ProfileSelectionModal extends Modal { @@ -11,17 +14,22 @@ export class ProfileSelectionModal extends Modal { onSubmit: (result: ProfileSelectionResult) => void; config: InterviewConfig; initialTitle: string; + defaultFolderPath: string; + private noteIndex: NoteIndex; constructor( app: App, config: InterviewConfig, onSubmit: (result: ProfileSelectionResult) => void, - initialTitle: string = "" + initialTitle: string = "", + defaultFolderPath: string = "" ) { super(app); this.config = config; this.onSubmit = onSubmit; this.initialTitle = initialTitle; + this.defaultFolderPath = defaultFolderPath; + this.noteIndex = new NoteIndex(app); } onOpen(): void { @@ -50,6 +58,7 @@ export class ProfileSelectionModal extends Modal { let selectedProfile: InterviewProfile | null = null; let titleInput: string = this.initialTitle; + let folderPath: string = this.defaultFolderPath; // Title input const titleSetting = new Setting(contentEl) @@ -63,6 +72,65 @@ export class ProfileSelectionModal extends Modal { text.inputEl.select(); }); + // Folder selection + const folderDisplay = contentEl.createEl("div", { cls: "folder-display" }); + folderDisplay.style.marginBottom = "1em"; + folderDisplay.style.padding = "0.5em"; + folderDisplay.style.backgroundColor = "var(--background-secondary)"; + folderDisplay.style.borderRadius = "4px"; + + const folderLabel = folderDisplay.createEl("span", { + text: "Folder: ", + cls: "folder-label", + }); + const folderPathSpan = folderDisplay.createEl("span", { + text: folderPath || "(root)", + cls: "folder-path", + }); + folderPathSpan.style.fontWeight = "bold"; + + const folderButtonContainer = folderDisplay.createEl("div"); + folderButtonContainer.style.display = "flex"; + folderButtonContainer.style.gap = "0.5em"; + folderButtonContainer.style.marginTop = "0.5em"; + + const pickFolderBtn = folderButtonContainer.createEl("button", { + text: "Pick…", + }); + pickFolderBtn.onclick = async () => { + const folderModal = new FolderTreeModal(this.app, this.noteIndex, folderPath); + const result = await folderModal.show(); + if (result) { + folderPath = result.folderPath; + folderPathSpan.textContent = folderPath || "(root)"; + } + }; + + const newFolderBtn = folderButtonContainer.createEl("button", { + text: "New folder…", + }); + newFolderBtn.onclick = async () => { + const folderName = await this.promptForFolderName(); + if (folderName && folderName.trim()) { + const newFolderPath = folderPath + ? `${folderPath}/${folderName.trim()}` + : folderName.trim(); + + try { + await this.app.vault.createFolder(newFolderPath); + folderPath = newFolderPath; + folderPathSpan.textContent = folderPath || "(root)"; + new Notice(`Folder created: ${newFolderPath}`); + + // Refresh note index to include new folder + this.noteIndex.refresh(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + new Notice(`Failed to create folder: ${msg}`); + } + } + }; + // Profile selection (grouped) for (const [groupName, profiles] of grouped.entries()) { contentEl.createEl("h3", { text: groupName }); @@ -83,12 +151,18 @@ export class ProfileSelectionModal extends Modal { setting.settingEl.addClass("profile-selected"); selectedProfile = profile; + // Update folder path from profile defaults + const profileFolder = (profile.defaults?.folder as string) || this.defaultFolderPath || ""; + folderPath = profileFolder; + folderPathSpan.textContent = folderPath || "(root)"; + // Log selection console.log("Profile selected", { key: profile.key, label: profile.label, noteType: profile.note_type, stepCount: profile.steps?.length || 0, + defaultFolder: profileFolder, }); }); }); @@ -117,12 +191,18 @@ export class ProfileSelectionModal extends Modal { setting.settingEl.addClass("profile-selected"); selectedProfile = profile; + // Update folder path from profile defaults + const profileFolder = (profile.defaults?.folder as string) || this.defaultFolderPath || ""; + folderPath = profileFolder; + folderPathSpan.textContent = folderPath || "(root)"; + // Log selection console.log("Profile selected", { key: profile.key, label: profile.label, noteType: profile.note_type, stepCount: profile.steps?.length || 0, + defaultFolder: profileFolder, }); }); }); @@ -147,6 +227,7 @@ export class ProfileSelectionModal extends Modal { this.result = { profile: selectedProfile, title: titleInput.trim(), + folderPath: folderPath.trim(), }; this.onSubmit(this.result); this.close(); @@ -154,6 +235,54 @@ export class ProfileSelectionModal extends Modal { }); } + private async promptForFolderName(): Promise { + return new Promise((resolve) => { + const modal = new Modal(this.app); + modal.titleEl.textContent = "New folder name"; + + const inputContainer = modal.contentEl.createEl("div"); + const input = inputContainer.createEl("input", { + type: "text", + placeholder: "Folder name", + }); + input.style.width = "100%"; + input.style.marginBottom = "1em"; + + const buttonContainer = modal.contentEl.createEl("div"); + buttonContainer.style.display = "flex"; + buttonContainer.style.gap = "0.5em"; + buttonContainer.style.justifyContent = "flex-end"; + + const cancelBtn = buttonContainer.createEl("button", { text: "Cancel" }); + cancelBtn.onclick = () => { + modal.close(); + resolve(null); + }; + + const createBtn = buttonContainer.createEl("button", { + text: "Create", + cls: "mod-cta", + }); + createBtn.onclick = () => { + const value = input.value.trim(); + modal.close(); + resolve(value || null); + }; + + input.onkeydown = (evt) => { + if (evt.key === "Enter") { + createBtn.click(); + } else if (evt.key === "Escape") { + cancelBtn.click(); + } + }; + + modal.open(); + input.focus(); + input.select(); + }); + } + onClose(): void { const { contentEl } = this; contentEl.empty();