Folder Picker V1
This commit is contained in:
parent
1f9211cbc5
commit
ae0e699602
49
src/main.ts
49
src/main.ts
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
93
src/mapping/folderHelpers.ts
Normal file
93
src/mapping/folderHelpers.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
36
src/tests/mapping/folderHelpers.test.ts
Normal file
36
src/tests/mapping/folderHelpers.test.ts
Normal 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
461
src/ui/FolderTreeModal.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user