Folder Picker V1
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run

This commit is contained in:
Lars 2026-01-17 08:01:04 +01:00
parent 1f9211cbc5
commit ae0e699602
7 changed files with 767 additions and 21 deletions

View File

@ -22,6 +22,7 @@ import { buildSemanticMappings } from "./mapping/semanticMappingBuilder";
import { ConfirmOverwriteModal } from "./ui/ConfirmOverwriteModal"; import { ConfirmOverwriteModal } from "./ui/ConfirmOverwriteModal";
import { GraphSchemaLoader } from "./schema/GraphSchemaLoader"; import { GraphSchemaLoader } from "./schema/GraphSchemaLoader";
import type { GraphSchema } from "./mapping/graphSchema"; import type { GraphSchema } from "./mapping/graphSchema";
import { joinFolderAndBasename, ensureUniqueFilePath, ensureFolderExists } from "./mapping/folderHelpers";
export default class MindnetCausalAssistantPlugin extends Plugin { export default class MindnetCausalAssistantPlugin extends Plugin {
settings: MindnetSettings; settings: MindnetSettings;
@ -79,14 +80,18 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
return; return;
} }
// Determine default folder (from profile defaults or settings)
const defaultFolder = ""; // Will be set per profile selection
// Open profile selection modal // Open profile selection modal
new ProfileSelectionModal( new ProfileSelectionModal(
this.app, this.app,
config, config,
async (result) => { async (result) => {
await this.createNoteFromProfileAndOpen(result, basename); await this.createNoteFromProfileAndOpen(result, basename, result.folderPath);
}, },
title title,
defaultFolder
).open(); ).open();
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(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 // Ignore errors getting initial title
} }
// Determine default folder (from profile defaults or settings)
const defaultFolder = ""; // Will be set per profile selection
// Show modal // Show modal
new ProfileSelectionModal( new ProfileSelectionModal(
this.app, this.app,
@ -365,7 +373,8 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
async (result) => { async (result) => {
await this.createNoteFromProfile(result); await this.createNoteFromProfile(result);
}, },
initialTitle initialTitle,
defaultFolder
).open(); ).open();
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
@ -429,8 +438,9 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
} }
private async createNoteFromProfileAndOpen( private async createNoteFromProfileAndOpen(
result: { profile: import("./interview/types").InterviewProfile; title: string }, result: { profile: import("./interview/types").InterviewProfile; title: string; folderPath?: string },
preferredBasename?: string preferredBasename?: string,
folderPath?: string
): Promise<void> { ): Promise<void> {
try { try {
const config = await this.ensureInterviewConfigLoaded(); const config = await this.ensureInterviewConfigLoaded();
@ -462,23 +472,24 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
// Create file content // Create file content
const content = `${frontmatter}\n\n`; const content = `${frontmatter}\n\n`;
// Determine file path (use filenameBase directly, preserving spaces) // Determine folder path (from result, parameter, profile defaults, or settings)
// Handle name conflicts by appending " (2)", " (3)", etc. (Obsidian style) const finalFolderPath = folderPath || result.folderPath ||
let fileName = `${filenameBase.trim()}.md`; (result.profile.defaults?.folder as string) ||
let filePath = fileName; this.settings.defaultNotesFolder ||
let attempt = 1; "";
while (true) { // Ensure folder exists
const existingFile = this.app.vault.getAbstractFileByPath(filePath); if (finalFolderPath) {
if (!existingFile) { await ensureFolderExists(this.app, finalFolderPath);
break; // File doesn't exist, we can use this path
}
attempt++;
fileName = `${filenameBase.trim()} (${attempt}).md`;
filePath = fileName;
} }
// 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 // Create file
const file = await this.app.vault.create(filePath, content); const file = await this.app.vault.create(filePath, content);

View File

@ -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<string> {
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<void> {
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);
}

View File

@ -15,6 +15,7 @@ export interface MindnetSettings {
defaultEdgeType: string; // default: "" defaultEdgeType: string; // default: ""
unassignedHandling: "prompt" | "none" | "defaultType" | "advisor"; // default: "prompt" unassignedHandling: "prompt" | "none" | "defaultType" | "advisor"; // default: "prompt"
allowOverwriteExistingMappings: boolean; // default: false allowOverwriteExistingMappings: boolean; // default: false
defaultNotesFolder: string; // default: "" (vault root)
} }
export const DEFAULT_SETTINGS: MindnetSettings = { export const DEFAULT_SETTINGS: MindnetSettings = {
@ -33,6 +34,7 @@ export interface MindnetSettings {
defaultEdgeType: "", defaultEdgeType: "",
unassignedHandling: "prompt", unassignedHandling: "prompt",
allowOverwriteExistingMappings: false, allowOverwriteExistingMappings: false,
defaultNotesFolder: "",
}; };
/** /**

View File

@ -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");
});
});

461
src/ui/FolderTreeModal.ts Normal file
View File

@ -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<string, FolderNodeState> = 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<string>();
// 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<import("../entityPicker/types").NoteIndexEntry> = [];
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<string | null> {
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<FolderTreeResult | null> {
return new Promise((resolve) => {
this.resolve = resolve;
this.open();
});
}
}

View File

@ -312,5 +312,19 @@ export class MindnetSettingTab extends PluginSettingTab {
await this.plugin.saveSettings(); 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();
})
);
} }
} }

View File

@ -1,9 +1,12 @@
import { App, Modal, Notice, Setting, TFile } from "obsidian"; import { App, Modal, Notice, Setting, TFile } from "obsidian";
import type { InterviewConfig, InterviewProfile } from "../interview/types"; import type { InterviewConfig, InterviewProfile } from "../interview/types";
import { FolderTreeModal, type FolderTreeResult } from "./FolderTreeModal";
import { NoteIndex } from "../entityPicker/noteIndex";
export interface ProfileSelectionResult { export interface ProfileSelectionResult {
profile: InterviewProfile; profile: InterviewProfile;
title: string; title: string;
folderPath: string; // Vault-relative path, empty string for root
} }
export class ProfileSelectionModal extends Modal { export class ProfileSelectionModal extends Modal {
@ -11,17 +14,22 @@ export class ProfileSelectionModal extends Modal {
onSubmit: (result: ProfileSelectionResult) => void; onSubmit: (result: ProfileSelectionResult) => void;
config: InterviewConfig; config: InterviewConfig;
initialTitle: string; initialTitle: string;
defaultFolderPath: string;
private noteIndex: NoteIndex;
constructor( constructor(
app: App, app: App,
config: InterviewConfig, config: InterviewConfig,
onSubmit: (result: ProfileSelectionResult) => void, onSubmit: (result: ProfileSelectionResult) => void,
initialTitle: string = "" initialTitle: string = "",
defaultFolderPath: string = ""
) { ) {
super(app); super(app);
this.config = config; this.config = config;
this.onSubmit = onSubmit; this.onSubmit = onSubmit;
this.initialTitle = initialTitle; this.initialTitle = initialTitle;
this.defaultFolderPath = defaultFolderPath;
this.noteIndex = new NoteIndex(app);
} }
onOpen(): void { onOpen(): void {
@ -50,6 +58,7 @@ export class ProfileSelectionModal extends Modal {
let selectedProfile: InterviewProfile | null = null; let selectedProfile: InterviewProfile | null = null;
let titleInput: string = this.initialTitle; let titleInput: string = this.initialTitle;
let folderPath: string = this.defaultFolderPath;
// Title input // Title input
const titleSetting = new Setting(contentEl) const titleSetting = new Setting(contentEl)
@ -63,6 +72,65 @@ export class ProfileSelectionModal extends Modal {
text.inputEl.select(); 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) // Profile selection (grouped)
for (const [groupName, profiles] of grouped.entries()) { for (const [groupName, profiles] of grouped.entries()) {
contentEl.createEl("h3", { text: groupName }); contentEl.createEl("h3", { text: groupName });
@ -83,12 +151,18 @@ export class ProfileSelectionModal extends Modal {
setting.settingEl.addClass("profile-selected"); setting.settingEl.addClass("profile-selected");
selectedProfile = profile; 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 // Log selection
console.log("Profile selected", { console.log("Profile selected", {
key: profile.key, key: profile.key,
label: profile.label, label: profile.label,
noteType: profile.note_type, noteType: profile.note_type,
stepCount: profile.steps?.length || 0, stepCount: profile.steps?.length || 0,
defaultFolder: profileFolder,
}); });
}); });
}); });
@ -117,12 +191,18 @@ export class ProfileSelectionModal extends Modal {
setting.settingEl.addClass("profile-selected"); setting.settingEl.addClass("profile-selected");
selectedProfile = profile; 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 // Log selection
console.log("Profile selected", { console.log("Profile selected", {
key: profile.key, key: profile.key,
label: profile.label, label: profile.label,
noteType: profile.note_type, noteType: profile.note_type,
stepCount: profile.steps?.length || 0, stepCount: profile.steps?.length || 0,
defaultFolder: profileFolder,
}); });
}); });
}); });
@ -147,6 +227,7 @@ export class ProfileSelectionModal extends Modal {
this.result = { this.result = {
profile: selectedProfile, profile: selectedProfile,
title: titleInput.trim(), title: titleInput.trim(),
folderPath: folderPath.trim(),
}; };
this.onSubmit(this.result); this.onSubmit(this.result);
this.close(); this.close();
@ -154,6 +235,54 @@ export class ProfileSelectionModal extends Modal {
}); });
} }
private async promptForFolderName(): Promise<string | null> {
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 { onClose(): void {
const { contentEl } = this; const { contentEl } = this;
contentEl.empty(); contentEl.empty();