From 78e8216ab9a373d54f2857a5df316a811456d917 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 17 Jan 2026 06:28:36 +0100 Subject: [PATCH] NotePicker - V1 --- src/entityPicker/filters.ts | 266 +++++++++++ src/entityPicker/folderTree.ts | 125 ++++++ src/entityPicker/noteIndex.ts | 162 +++++++ src/entityPicker/types.ts | 48 ++ src/entityPicker/wikilink.ts | 61 +++ src/interview/parseInterviewConfig.ts | 27 ++ src/interview/renderer.ts | 30 +- src/interview/types.ts | 12 +- src/tests/entityPicker/filters.test.ts | 151 +++++++ src/tests/entityPicker/wikilink.test.ts | 56 +++ src/ui/EntityPickerModal.ts | 558 ++++++++++++++++++++++++ src/ui/InterviewWizardModal.ts | 142 +++++- src/ui/markdownToolbar.ts | 17 +- 13 files changed, 1651 insertions(+), 4 deletions(-) create mode 100644 src/entityPicker/filters.ts create mode 100644 src/entityPicker/folderTree.ts create mode 100644 src/entityPicker/noteIndex.ts create mode 100644 src/entityPicker/types.ts create mode 100644 src/entityPicker/wikilink.ts create mode 100644 src/tests/entityPicker/filters.test.ts create mode 100644 src/tests/entityPicker/wikilink.test.ts create mode 100644 src/ui/EntityPickerModal.ts diff --git a/src/entityPicker/filters.ts b/src/entityPicker/filters.ts new file mode 100644 index 0000000..76501cd --- /dev/null +++ b/src/entityPicker/filters.ts @@ -0,0 +1,266 @@ +/** + * Pure functions for filtering and sorting notes. + * Unit tested and deterministic. + */ + +import type { NoteIndexEntry, FilterOptions, SortOptions, GroupedNoteResult } from "./types"; + +/** + * Check if a path is within a folder (including subfolders). + */ +function isPathInFolder(path: string, folderPath: string | null | undefined): boolean { + if (!folderPath) { + return true; // No folder filter = include all + } + + if (folderPath === "") { + // Root folder: include only files directly in root + return !path.includes("/"); + } + + // Check if path starts with folder path (with trailing slash) + const normalizedFolder = folderPath.endsWith("/") ? folderPath : folderPath + "/"; + return path.startsWith(normalizedFolder) || path === folderPath; +} + +/** + * Score a note for search relevance. + * Higher score = better match. + */ +function scoreSearchRelevance(entry: NoteIndexEntry, searchQuery: string): number { + if (!searchQuery || !searchQuery.trim()) { + return 0; + } + + const query = searchQuery.toLowerCase().trim(); + const basenameLower = entry.basename.toLowerCase(); + const titleLower = entry.title?.toLowerCase() || ""; + + // Exact match on basename or title + if (basenameLower === query || titleLower === query) { + return 100; + } + + // Prefix match on basename or title + if (basenameLower.startsWith(query) || titleLower.startsWith(query)) { + return 80; + } + + // Contains match on basename or title + if (basenameLower.includes(query) || titleLower.includes(query)) { + return 60; + } + + // Contains match on path + if (entry.path.toLowerCase().includes(query)) { + return 40; + } + + return 0; +} + +/** + * Apply filters to a list of notes. + */ +export function applyFilters( + notes: NoteIndexEntry[], + options: FilterOptions +): NoteIndexEntry[] { + let filtered = notes; + + // Folder filter + if (options.selectedFolderPath !== undefined && options.selectedFolderPath !== null) { + filtered = filtered.filter((note) => + isPathInFolder(note.path, options.selectedFolderPath) + ); + } + + // Type filter + if (options.typeSet && options.typeSet.size > 0) { + filtered = filtered.filter((note) => { + if (note.type === null) { + return options.includeNoneType === true; + } + return options.typeSet!.has(note.type); + }); + } else if (options.includeNoneType === false) { + // If includeNoneType is explicitly false and no types selected, exclude null types + filtered = filtered.filter((note) => note.type !== null); + } + + // Search filter (with scoring) + if (options.search && options.search.trim()) { + const query = options.search.trim(); + const scored = filtered.map((note) => ({ + note, + score: scoreSearchRelevance(note, query), + })); + + // Only include notes with score > 0 + filtered = scored + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score) // Sort by score descending + .map((item) => item.note); + } + + return filtered; +} + +/** + * Sort notes by mode. + */ +export function sortNotes( + notes: NoteIndexEntry[], + options: SortOptions +): NoteIndexEntry[] { + const sorted = [...notes]; + + switch (options.mode) { + case "recent": + sorted.sort((a, b) => { + // Primary: mtime descending + if (b.mtime !== a.mtime) { + return b.mtime - a.mtime; + } + // Tie-break: alphabetical by basename + return a.basename.localeCompare(b.basename); + }); + break; + + case "alpha": + sorted.sort((a, b) => a.basename.localeCompare(b.basename)); + break; + + case "type": + // Will be grouped, but sort items within groups + sorted.sort((a, b) => { + const typeA = a.type || "(none)"; + const typeB = b.type || "(none)"; + if (typeA !== typeB) { + return typeA.localeCompare(typeB); + } + // Within same type, sort by search score if available, then alpha + if (options.searchQuery) { + const scoreA = scoreSearchRelevance(a, options.searchQuery); + const scoreB = scoreSearchRelevance(b, options.searchQuery); + if (scoreB !== scoreA) { + return scoreB - scoreA; + } + } + return a.basename.localeCompare(b.basename); + }); + break; + + case "folder": + // Will be grouped, but sort items within groups + sorted.sort((a, b) => { + if (a.folder !== b.folder) { + return a.folder.localeCompare(b.folder); + } + // Within same folder, sort by search score if available, then alpha + if (options.searchQuery) { + const scoreA = scoreSearchRelevance(a, options.searchQuery); + const scoreB = scoreSearchRelevance(b, options.searchQuery); + if (scoreB !== scoreA) { + return scoreB - scoreA; + } + } + return a.basename.localeCompare(b.basename); + }); + break; + } + + return sorted; +} + +/** + * Group notes by type. + */ +export function groupByType( + notes: NoteIndexEntry[], + searchQuery?: string +): GroupedNoteResult[] { + const groups = new Map(); + + for (const note of notes) { + const groupKey = note.type || "(none)"; + if (!groups.has(groupKey)) { + groups.set(groupKey, []); + } + groups.get(groupKey)!.push(note); + } + + // Convert to array and sort groups alphabetically + const result: GroupedNoteResult[] = Array.from(groups.entries()) + .map(([groupKey, groupNotes]) => { + // Sort items within group by search score (if available), then alpha + const sorted = [...groupNotes]; + if (searchQuery) { + sorted.sort((a, b) => { + const scoreA = scoreSearchRelevance(a, searchQuery); + const scoreB = scoreSearchRelevance(b, searchQuery); + if (scoreB !== scoreA) { + return scoreB - scoreA; + } + return a.basename.localeCompare(b.basename); + }); + } else { + sorted.sort((a, b) => a.basename.localeCompare(b.basename)); + } + + return { + groupKey, + groupLabel: groupKey, + notes: sorted, + }; + }) + .sort((a, b) => a.groupKey.localeCompare(b.groupKey)); + + return result; +} + +/** + * Group notes by folder. + */ +export function groupByFolder( + notes: NoteIndexEntry[], + searchQuery?: string +): GroupedNoteResult[] { + const groups = new Map(); + + for (const note of notes) { + const groupKey = note.folder || "(root)"; + if (!groups.has(groupKey)) { + groups.set(groupKey, []); + } + groups.get(groupKey)!.push(note); + } + + // Convert to array and sort groups alphabetically + const result: GroupedNoteResult[] = Array.from(groups.entries()) + .map(([groupKey, groupNotes]) => { + // Sort items within group by search score (if available), then alpha + const sorted = [...groupNotes]; + if (searchQuery) { + sorted.sort((a, b) => { + const scoreA = scoreSearchRelevance(a, searchQuery); + const scoreB = scoreSearchRelevance(b, searchQuery); + if (scoreB !== scoreA) { + return scoreB - scoreA; + } + return a.basename.localeCompare(b.basename); + }); + } else { + sorted.sort((a, b) => a.basename.localeCompare(b.basename)); + } + + return { + groupKey, + groupLabel: groupKey || "(root)", + notes: sorted, + }; + }) + .sort((a, b) => a.groupKey.localeCompare(b.groupKey)); + + return result; +} diff --git a/src/entityPicker/folderTree.ts b/src/entityPicker/folderTree.ts new file mode 100644 index 0000000..6c4764b --- /dev/null +++ b/src/entityPicker/folderTree.ts @@ -0,0 +1,125 @@ +/** + * Folder Tree: Builds and manages folder tree structure from note paths. + */ + +import type { NoteIndexEntry, FolderTreeNode } from "./types"; + +/** + * Build folder tree from note entries. + */ +export function buildFolderTree(entries: NoteIndexEntry[]): FolderTreeNode { + const root: FolderTreeNode = { + name: "All folders", + path: "", + children: [], + noteCount: 0, + }; + + const nodeMap = new Map(); + nodeMap.set("", root); + + // Count notes per folder + const folderCounts = new Map(); + for (const entry of entries) { + const folder = entry.folder || ""; + folderCounts.set(folder, (folderCounts.get(folder) || 0) + 1); + } + + // Build tree structure + for (const entry of entries) { + const folder = entry.folder || ""; + const parts = folder.split("/").filter((p) => p); + + let currentPath = ""; + let currentNode = root; + + for (const part of parts) { + const parentPath = currentPath; + currentPath = currentPath ? `${currentPath}/${part}` : part; + + if (!nodeMap.has(currentPath)) { + const newNode: FolderTreeNode = { + name: part, + path: currentPath, + children: [], + noteCount: 0, + }; + nodeMap.set(currentPath, newNode); + currentNode.children.push(newNode); + } + + currentNode = nodeMap.get(currentPath)!; + } + + // Increment note count for this folder + currentNode.noteCount = folderCounts.get(folder) || 0; + } + + // Update root count + root.noteCount = entries.length; + + // Sort children alphabetically + function sortTree(node: FolderTreeNode): void { + node.children.sort((a, b) => a.name.localeCompare(b.name)); + for (const child of node.children) { + sortTree(child); + } + } + sortTree(root); + + return root; +} + +/** + * Get all folder paths in a subtree (including the folder itself). + */ +export function getSubtreePaths( + tree: FolderTreeNode, + folderPath: string | null +): Set { + const paths = new Set(); + + if (!folderPath || folderPath === "") { + // Include all folders + function collectPaths(node: FolderTreeNode): void { + if (node.path !== "") { + paths.add(node.path); + } + for (const child of node.children) { + collectPaths(child); + } + } + collectPaths(tree); + return paths; + } + + // Find the node for this folder path + function findNode(node: FolderTreeNode, targetPath: string): FolderTreeNode | null { + if (node.path === targetPath) { + return node; + } + for (const child of node.children) { + const found = findNode(child, targetPath); + if (found) { + return found; + } + } + return null; + } + + const targetNode = findNode(tree, folderPath); + if (!targetNode) { + return paths; + } + + // Collect all paths in subtree + function collectSubtree(node: FolderTreeNode): void { + paths.add(node.path); + for (const child of node.children) { + collectSubtree(child); + } + } + collectSubtree(targetNode); + + return paths; +} diff --git a/src/entityPicker/noteIndex.ts b/src/entityPicker/noteIndex.ts new file mode 100644 index 0000000..edc5bb2 --- /dev/null +++ b/src/entityPicker/noteIndex.ts @@ -0,0 +1,162 @@ +/** + * Note Index: Builds and maintains an index of all markdown files in the vault. + * Provides incremental updates on vault events. + */ + +import { App, TFile, debounce } from "obsidian"; +import type { NoteIndexEntry } from "./types"; + +export class NoteIndex { + private app: App; + private index: Map = new Map(); + private listeners: Set<() => void> = new Set(); + private debouncedRefresh: () => void; + + constructor(app: App) { + this.app = app; + // Debounce refresh to avoid excessive updates during bulk operations + this.debouncedRefresh = debounce(() => this.refresh(), 300, true); + + // Listen to vault events + this.app.vault.on("create", (file) => { + if (file instanceof TFile && file.extension === "md") { + this.debouncedRefresh(); + } + }); + + this.app.vault.on("modify", (file) => { + if (file instanceof TFile && file.extension === "md") { + this.debouncedRefresh(); + } + }); + + this.app.vault.on("delete", (file) => { + if (file instanceof TFile && file.extension === "md") { + this.debouncedRefresh(); + } + }); + + this.app.vault.on("rename", (file, oldPath) => { + if (file instanceof TFile && file.extension === "md") { + this.debouncedRefresh(); + } + }); + + // Initial build + this.refresh(); + } + + /** + * Refresh the entire index. + */ + refresh(): void { + const newIndex = new Map(); + const markdownFiles = this.app.vault.getMarkdownFiles(); + + for (const file of markdownFiles) { + const entry = this.buildEntry(file); + if (entry) { + newIndex.set(entry.path, entry); + } + } + + this.index = newIndex; + this.notifyListeners(); + } + + /** + * Build a single index entry from a file. + */ + private buildEntry(file: TFile): NoteIndexEntry | null { + try { + const path = file.path; + const folder = file.parent?.path || "/"; + const basename = file.basename; + + // Get metadata from cache (prefer over reading file content) + const cache = this.app.metadataCache.getFileCache(file); + const frontmatter = cache?.frontmatter; + + const title = frontmatter?.title as string | undefined || null; + const type = frontmatter?.type as string | undefined || null; + + // Get modification time + const stat = file.stat; + const mtime = stat?.mtime || 0; + + return { + path, + folder: folder === "/" ? "" : folder, + basename, + title, + type, + mtime, + }; + } catch (error) { + console.error("Error building index entry for", file.path, error); + return null; + } + } + + /** + * Get all entries. + */ + getAllEntries(): NoteIndexEntry[] { + return Array.from(this.index.values()); + } + + /** + * Get entry by path. + */ + getEntry(path: string): NoteIndexEntry | undefined { + return this.index.get(path); + } + + /** + * Get entry by basename (first match). + */ + getEntryByBasename(basename: string): NoteIndexEntry | undefined { + for (const entry of this.index.values()) { + if (entry.basename === basename) { + return entry; + } + } + return undefined; + } + + /** + * Get all unique types from the index. + */ + getAllTypes(): string[] { + const types = new Set(); + for (const entry of this.index.values()) { + if (entry.type) { + types.add(entry.type); + } + } + return Array.from(types).sort(); + } + + /** + * Subscribe to index updates. + */ + onUpdate(callback: () => void): () => void { + this.listeners.add(callback); + return () => { + this.listeners.delete(callback); + }; + } + + /** + * Notify all listeners of an update. + */ + private notifyListeners(): void { + for (const listener of this.listeners) { + try { + listener(); + } catch (error) { + console.error("Error in index update listener", error); + } + } + } +} diff --git a/src/entityPicker/types.ts b/src/entityPicker/types.ts new file mode 100644 index 0000000..a30ac25 --- /dev/null +++ b/src/entityPicker/types.ts @@ -0,0 +1,48 @@ +/** + * Types for Entity Picker (Note Picker) system. + */ + +export interface NoteIndexEntry { + path: string; + folder: string; + basename: string; + title: string | null; + type: string | null; + mtime: number; +} + +export interface FolderTreeNode { + name: string; + path: string; + children: FolderTreeNode[]; + noteCount: number; +} + +export interface FilterOptions { + search?: string; + selectedFolderPath?: string | null; + typeSet?: Set; + includeNoneType?: boolean; +} + +export type SortMode = "recent" | "alpha" | "type" | "folder"; + +export interface SortOptions { + mode: SortMode; + searchQuery?: string; // For scoring relevance +} + +export interface GroupedNoteResult { + groupKey: string; + groupLabel: string; + notes: NoteIndexEntry[]; +} + +export interface EntityPickerStep { + type: "entity_picker"; + key: string; + label: string; + prompt?: string; + required?: boolean; + labelField?: string; // Optional field key to use as wikilink label +} diff --git a/src/entityPicker/wikilink.ts b/src/entityPicker/wikilink.ts new file mode 100644 index 0000000..32e97da --- /dev/null +++ b/src/entityPicker/wikilink.ts @@ -0,0 +1,61 @@ +/** + * Wikilink insertion utilities. + * Pure functions for inserting wikilinks into text. + */ + +/** + * Insert a wikilink into text, replacing selection if present. + * + * @param text - The full text + * @param selStart - Selection start position + * @param selEnd - Selection end position + * @param basename - The note basename to link to + * @returns Object with new text and new cursor position + */ +export function insertWikilink( + text: string, + selStart: number, + selEnd: number, + basename: string +): { text: string; cursorPos: number } { + const hasSelection = selEnd > selStart; + + let wikilink: string; + if (hasSelection) { + const selectedText = text.substring(selStart, selEnd); + wikilink = `[[${basename}|${selectedText}]]`; + } else { + wikilink = `[[${basename}]]`; + } + + // Replace selection with wikilink + const before = text.substring(0, selStart); + const after = text.substring(selEnd); + const newText = before + wikilink + after; + + // Calculate new cursor position (after the wikilink) + const cursorPos = selStart + wikilink.length; + + return { text: newText, cursorPos }; +} + +/** + * Insert wikilink into a textarea element. + * Updates the textarea value and cursor position. + */ +export function insertWikilinkIntoTextarea( + textarea: HTMLTextAreaElement, + basename: string +): void { + const selStart = textarea.selectionStart; + const selEnd = textarea.selectionEnd; + const currentText = textarea.value; + + const result = insertWikilink(currentText, selStart, selEnd, basename); + + textarea.value = result.text; + textarea.setSelectionRange(result.cursorPos, result.cursorPos); + + // Trigger input event so Obsidian can update its state + textarea.dispatchEvent(new Event("input", { bubbles: true })); +} diff --git a/src/interview/parseInterviewConfig.ts b/src/interview/parseInterviewConfig.ts index fc01ed4..12f1824 100644 --- a/src/interview/parseInterviewConfig.ts +++ b/src/interview/parseInterviewConfig.ts @@ -377,6 +377,33 @@ function parseStep(raw: Record): InterviewStep | null { return step; } + if (type === "entity_picker") { + const key = getKey(); + if (!key) { + return null; + } + + const step: InterviewStep = { + type: "entity_picker", + key: key, + }; + + if (typeof raw.label === "string" && raw.label.trim()) { + step.label = raw.label.trim(); + } + if (typeof raw.prompt === "string" && raw.prompt.trim()) { + step.prompt = raw.prompt.trim(); + } + if (typeof raw.required === "boolean") { + step.required = raw.required; + } + if (typeof raw.labelField === "string" && raw.labelField.trim()) { + step.labelField = raw.labelField.trim(); + } + + return step; + } + if (type === "review") { const key = getKey(); if (!key) { diff --git a/src/interview/renderer.ts b/src/interview/renderer.ts index f7eddcc..6073205 100644 --- a/src/interview/renderer.ts +++ b/src/interview/renderer.ts @@ -3,7 +3,7 @@ * Converts collected answers into markdown output based on profile configuration. */ -import type { InterviewProfile, InterviewStep, CaptureTextStep, CaptureTextLineStep, CaptureFrontmatterStep, LoopStep } from "./types"; +import type { InterviewProfile, InterviewStep, CaptureTextStep, CaptureTextLineStep, CaptureFrontmatterStep, LoopStep, EntityPickerStep } from "./types"; export interface RenderAnswers { collectedData: Map; @@ -44,6 +44,8 @@ function renderStep(step: InterviewStep, answers: RenderAnswers): string | null return null; case "loop": return renderLoop(step, answers); + case "entity_picker": + return renderEntityPicker(step, answers); case "instruction": case "llm_dialog": case "review": @@ -122,6 +124,32 @@ function renderCaptureTextLine(step: CaptureTextLineStep, answers: RenderAnswers return headingPrefix + text; } +/** + * Render entity_picker step. + */ +function renderEntityPicker(step: EntityPickerStep, answers: RenderAnswers): string | null { + const basename = answers.collectedData.get(step.key); + if (!basename || typeof basename !== "string" || !basename.trim()) { + return null; + } + + // Check if there's a label field + let label: string | undefined; + if (step.labelField) { + const labelValue = answers.collectedData.get(step.labelField); + if (labelValue && typeof labelValue === "string" && labelValue.trim()) { + label = labelValue.trim(); + } + } + + // Render wikilink + if (label) { + return `[[${basename}|${label}]]`; + } else { + return `[[${basename}]]`; + } +} + /** * Render capture_frontmatter step. * Note: This is typically handled separately, but included for completeness. diff --git a/src/interview/types.ts b/src/interview/types.ts index 6091842..53373ad 100644 --- a/src/interview/types.ts +++ b/src/interview/types.ts @@ -24,7 +24,8 @@ export type InterviewStep = | CaptureTextStep | CaptureTextLineStep | InstructionStep - | ReviewStep; + | ReviewStep + | EntityPickerStep; export interface LoopStep { type: "loop"; @@ -96,6 +97,15 @@ export interface InstructionStep { content: string; } +export interface EntityPickerStep { + type: "entity_picker"; + key: string; + label?: string; + prompt?: string; + required?: boolean; + labelField?: string; // Optional field key to use as wikilink label +} + export interface ReviewStep { type: "review"; key: string; diff --git a/src/tests/entityPicker/filters.test.ts b/src/tests/entityPicker/filters.test.ts new file mode 100644 index 0000000..bf6720d --- /dev/null +++ b/src/tests/entityPicker/filters.test.ts @@ -0,0 +1,151 @@ +/** + * Unit tests for filtering and sorting functions. + */ + +import { describe, it, expect } from "vitest"; +import { applyFilters, sortNotes, groupByType, groupByFolder } from "../../entityPicker/filters"; +import type { NoteIndexEntry, FilterOptions, SortOptions } from "../../entityPicker/types"; + +const createEntry = ( + basename: string, + folder: string = "", + type: string | null = null, + mtime: number = 1000 +): NoteIndexEntry => ({ + path: folder ? `${folder}/${basename}.md` : `${basename}.md`, + folder, + basename, + title: null, + type, + mtime, +}); + +describe("applyFilters", () => { + const notes: NoteIndexEntry[] = [ + createEntry("Note1", "folder1", "type1"), + createEntry("Note2", "folder1/subfolder", "type1"), + createEntry("Note3", "folder2", "type2"), + createEntry("Note4", "", "type1"), + ]; + + it("should filter by folder (including subfolders)", () => { + const options: FilterOptions = { + selectedFolderPath: "folder1", + }; + const result = applyFilters(notes, options); + + expect(result).toHaveLength(2); + expect(result.map((n) => n.basename)).toEqual(["Note1", "Note2"]); + }); + + it("should filter by type", () => { + const options: FilterOptions = { + typeSet: new Set(["type1"]), + }; + const result = applyFilters(notes, options); + + expect(result).toHaveLength(3); + expect(result.map((n) => n.basename)).toEqual(["Note1", "Note2", "Note4"]); + }); + + it("should filter by search query", () => { + const options: FilterOptions = { + search: "Note1", + }; + const result = applyFilters(notes, options); + + expect(result).toHaveLength(1); + expect(result[0]?.basename).toBe("Note1"); + }); + + it("should combine multiple filters", () => { + const options: FilterOptions = { + selectedFolderPath: "folder1", + typeSet: new Set(["type1"]), + search: "Note", + }; + const result = applyFilters(notes, options); + + expect(result).toHaveLength(2); + expect(result.map((n) => n.basename)).toEqual(["Note1", "Note2"]); + }); + + it("should include none type when requested", () => { + const notesWithNone = [ + ...notes, + createEntry("Note5", "folder1", null), + ]; + const options: FilterOptions = { + typeSet: new Set(["type1"]), + includeNoneType: true, + }; + const result = applyFilters(notesWithNone, options); + + const hasNote5 = result.some((n) => n.basename === "Note5"); + expect(hasNote5).toBe(true); + }); +}); + +describe("sortNotes", () => { + const notes: NoteIndexEntry[] = [ + createEntry("NoteC", "", null, 3000), + createEntry("NoteA", "", null, 1000), + createEntry("NoteB", "", null, 2000), + ]; + + it("should sort by recent (mtime desc)", () => { + const options: SortOptions = { mode: "recent" }; + const result = sortNotes(notes, options); + + expect(result.map((n) => n.basename)).toEqual(["NoteC", "NoteB", "NoteA"]); + }); + + it("should sort alphabetically", () => { + const options: SortOptions = { mode: "alpha" }; + const result = sortNotes(notes, options); + + expect(result.map((n) => n.basename)).toEqual(["NoteA", "NoteB", "NoteC"]); + }); +}); + +describe("groupByType", () => { + const notes: NoteIndexEntry[] = [ + createEntry("Note1", "", "type1"), + createEntry("Note2", "", "type2"), + createEntry("Note3", "", "type1"), + createEntry("Note4", "", null), + ]; + + it("should group notes by type", () => { + const result = groupByType(notes); + + expect(result).toHaveLength(3); + const type1Group = result.find((g) => g.groupKey === "type1"); + const type2Group = result.find((g) => g.groupKey === "type2"); + const noneGroup = result.find((g) => g.groupKey === "(none)"); + expect(type1Group?.notes).toHaveLength(2); + expect(type2Group?.notes).toHaveLength(1); + expect(noneGroup?.notes).toHaveLength(1); + }); +}); + +describe("groupByFolder", () => { + const notes: NoteIndexEntry[] = [ + createEntry("Note1", "folder1"), + createEntry("Note2", "folder2"), + createEntry("Note3", "folder1"), + createEntry("Note4", ""), + ]; + + it("should group notes by folder", () => { + const result = groupByFolder(notes); + + expect(result).toHaveLength(3); + const folder1Group = result.find((g) => g.groupKey === "folder1"); + const folder2Group = result.find((g) => g.groupKey === "folder2"); + const rootGroup = result.find((g) => g.groupKey === "(root)"); + expect(folder1Group?.notes).toHaveLength(2); + expect(folder2Group?.notes).toHaveLength(1); + expect(rootGroup?.notes).toHaveLength(1); + }); +}); diff --git a/src/tests/entityPicker/wikilink.test.ts b/src/tests/entityPicker/wikilink.test.ts new file mode 100644 index 0000000..d52181d --- /dev/null +++ b/src/tests/entityPicker/wikilink.test.ts @@ -0,0 +1,56 @@ +/** + * Unit tests for wikilink insertion functions. + */ + +import { describe, it, expect } from "vitest"; +import { insertWikilink } from "../../entityPicker/wikilink"; + +describe("insertWikilink", () => { + it("should insert wikilink when no selection", () => { + const text = "Hello world"; + const result = insertWikilink(text, 6, 6, "MyNote"); + + expect(result.text).toBe("Hello [[MyNote]]world"); + expect(result.cursorPos).toBe(18); // After "Hello [[MyNote]]" + }); + + it("should replace selection with wikilink containing selected text", () => { + const text = "Hello world"; + const result = insertWikilink(text, 0, 5, "MyNote"); + + expect(result.text).toBe("[[MyNote|Hello]] world"); + expect(result.cursorPos).toBe(20); // After "[[MyNote|Hello]]" + }); + + it("should handle multiline selection", () => { + const text = "Line 1\nLine 2\nLine 3"; + const result = insertWikilink(text, 0, 12, "MyNote"); + + expect(result.text).toBe("[[MyNote|Line 1\nLine 2]]\nLine 3"); + expect(result.cursorPos).toBe(27); // After "[[MyNote|Line 1\nLine 2]]" + }); + + it("should not delete unrelated text", () => { + const text = "Before selection after"; + const result = insertWikilink(text, 7, 16, "MyNote"); + + expect(result.text).toBe("Before [[MyNote|selection]] after"); + expect(result.cursorPos).toBe(31); // After "[[MyNote|selection]]" + }); + + it("should handle empty text", () => { + const text = ""; + const result = insertWikilink(text, 0, 0, "MyNote"); + + expect(result.text).toBe("[[MyNote]]"); + expect(result.cursorPos).toBe(10); + }); + + it("should handle selection at end of text", () => { + const text = "Hello "; + const result = insertWikilink(text, 6, 6, "MyNote"); + + expect(result.text).toBe("Hello [[MyNote]]"); + expect(result.cursorPos).toBe(16); + }); +}); diff --git a/src/ui/EntityPickerModal.ts b/src/ui/EntityPickerModal.ts new file mode 100644 index 0000000..26a78f8 --- /dev/null +++ b/src/ui/EntityPickerModal.ts @@ -0,0 +1,558 @@ +/** + * Entity Picker Modal: Fast Obsidian-optimized note picker. + * Supports search, folder tree, type filter, sorting, and grouping. + */ + +import { + App, + Modal, + Setting, + TFile, +} from "obsidian"; +import { NoteIndex } from "../entityPicker/noteIndex"; +import { + applyFilters, + sortNotes, + groupByType, + groupByFolder, +} from "../entityPicker/filters"; +import { + buildFolderTree, + getSubtreePaths, +} from "../entityPicker/folderTree"; +import type { + NoteIndexEntry, + FilterOptions, + SortOptions, + FolderTreeNode, + GroupedNoteResult, +} from "../entityPicker/types"; + +export interface EntityPickerResult { + basename: string; + path: string; +} + +export class EntityPickerModal extends Modal { + private noteIndex: NoteIndex; + private onSelect: (result: EntityPickerResult) => void; + + // State + private searchQuery: string = ""; + private selectedFolderPath: string | null = null; + private selectedTypes: Set = new Set(); + private includeNoneType: boolean = true; + private sortMode: "recent" | "alpha" | "type" | "folder" = "recent"; + + // UI elements + private searchInputEl: HTMLInputElement | null = null; + private folderTreeContainer: HTMLElement | null = null; + private resultsContainer: HTMLElement | null = null; + private typeFilterContainer: HTMLElement | null = null; + + // Folder tree state + private expandedFolders: Set = new Set(); + private folderTree: FolderTreeNode | null = null; + + constructor( + app: App, + noteIndex: NoteIndex, + onSelect: (result: EntityPickerResult) => void + ) { + super(app); + this.noteIndex = noteIndex; + this.onSelect = onSelect; + + // Initialize expanded folders (expand root by default) + this.expandedFolders.add(""); + + // Subscribe to index updates + this.noteIndex.onUpdate(() => { + this.refresh(); + }); + } + + onOpen(): void { + const { contentEl, modalEl } = this; + contentEl.empty(); + contentEl.addClass("entity-picker-modal"); + + // Make modal larger + modalEl.style.width = "clamp(900px, 90vw, 1400px)"; + modalEl.style.height = "clamp(600px, 85vh, 900px)"; + modalEl.style.maxWidth = "90vw"; + modalEl.style.maxHeight = "85vh"; + + // Build folder tree + this.folderTree = buildFolderTree(this.noteIndex.getAllEntries()); + + // Header: Search + Sort + const header = contentEl.createEl("div", { cls: "entity-picker-header" }); + header.style.display = "flex"; + header.style.gap = "0.5em"; + header.style.marginBottom = "1em"; + header.style.alignItems = "center"; + + // Search input + const searchContainer = header.createEl("div"); + searchContainer.style.flex = "1"; + const searchSetting = new Setting(searchContainer); + searchSetting.settingEl.style.border = "none"; + searchSetting.settingEl.style.padding = "0"; + searchSetting.addText((text) => { + this.searchInputEl = text.inputEl; + text.setPlaceholder("Search notes..."); + text.setValue(this.searchQuery); + text.onChange((value) => { + this.searchQuery = value; + this.refresh(); + }); + text.inputEl.style.width = "100%"; + }); + + // Sort dropdown + const sortContainer = header.createEl("div"); + sortContainer.style.flexShrink = "0"; + const sortSetting = new Setting(sortContainer); + sortSetting.settingEl.style.border = "none"; + sortSetting.settingEl.style.padding = "0"; + sortSetting.addDropdown((dropdown) => { + dropdown.addOption("recent", "Recent"); + dropdown.addOption("alpha", "Alphabetical"); + dropdown.addOption("type", "By Type"); + dropdown.addOption("folder", "By Folder"); + dropdown.setValue(this.sortMode); + dropdown.onChange((value) => { + this.sortMode = value as "recent" | "alpha" | "type" | "folder"; + this.refresh(); + }); + }); + + // Type filter button + const typeFilterBtn = header.createEl("button", { + text: "Types", + cls: "mod-cta", + }); + typeFilterBtn.style.flexShrink = "0"; + typeFilterBtn.onclick = () => { + this.toggleTypeFilter(); + }; + + // Main container: 2 panes + const mainContainer = contentEl.createEl("div", { + cls: "entity-picker-main", + }); + mainContainer.style.display = "flex"; + mainContainer.style.gap = "1em"; + mainContainer.style.height = "calc(85vh - 120px)"; // Account for header and padding + mainContainer.style.minHeight = "500px"; + + // Left pane: Folder tree (wider) + const leftPane = mainContainer.createEl("div", { + cls: "entity-picker-left", + }); + leftPane.style.width = "40%"; + leftPane.style.minWidth = "300px"; + leftPane.style.border = "1px solid var(--background-modifier-border)"; + leftPane.style.borderRadius = "4px"; + leftPane.style.padding = "0.75em"; + leftPane.style.overflowY = "auto"; + this.folderTreeContainer = leftPane; + + // Right pane: Results + const rightPane = mainContainer.createEl("div", { + cls: "entity-picker-right", + }); + rightPane.style.flex = "1"; + rightPane.style.border = "1px solid var(--background-modifier-border)"; + rightPane.style.borderRadius = "4px"; + rightPane.style.padding = "0.75em"; + rightPane.style.overflowY = "auto"; + this.resultsContainer = rightPane; + + // Type filter popover (hidden by default) + this.typeFilterContainer = contentEl.createEl("div", { + cls: "entity-picker-type-filter", + }); + this.typeFilterContainer.style.display = "none"; + this.typeFilterContainer.style.position = "absolute"; + this.typeFilterContainer.style.background = "var(--background-primary)"; + this.typeFilterContainer.style.border = "1px solid var(--background-modifier-border)"; + this.typeFilterContainer.style.borderRadius = "4px"; + this.typeFilterContainer.style.padding = "0.5em"; + this.typeFilterContainer.style.zIndex = "1000"; + this.typeFilterContainer.style.maxHeight = "300px"; + this.typeFilterContainer.style.overflowY = "auto"; + + // Initial render + this.refresh(); + + // Focus search input + setTimeout(() => { + this.searchInputEl?.focus(); + }, 100); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } + + private refresh(): void { + if (!this.folderTreeContainer || !this.resultsContainer) { + return; + } + + // Render folder tree + this.renderFolderTree(); + + // Render results + this.renderResults(); + + // Render type filter + this.renderTypeFilter(); + } + + private renderFolderTree(): void { + if (!this.folderTreeContainer || !this.folderTree) { + return; + } + + this.folderTreeContainer.empty(); + + const renderNode = (node: FolderTreeNode, depth: number = 0): void => { + const nodeEl = this.folderTreeContainer!.createEl("div", { + cls: "folder-tree-node", + }); + nodeEl.style.paddingLeft = `${depth * 1.5}em`; + nodeEl.style.paddingTop = "0.25em"; + nodeEl.style.paddingBottom = "0.25em"; + nodeEl.style.cursor = "pointer"; + nodeEl.style.display = "flex"; + nodeEl.style.alignItems = "center"; + nodeEl.style.gap = "0.5em"; + + // Highlight if selected + if (node.path === this.selectedFolderPath) { + nodeEl.style.background = "var(--background-modifier-active-hover)"; + } + + // Expand/collapse button (if has children) + if (node.children.length > 0) { + const expandBtn = nodeEl.createEl("span", { + text: this.expandedFolders.has(node.path) ? "▼" : "▶", + cls: "folder-tree-expand", + }); + expandBtn.style.width = "1em"; + expandBtn.style.fontSize = "0.8em"; + expandBtn.onclick = (e) => { + e.stopPropagation(); + if (this.expandedFolders.has(node.path)) { + this.expandedFolders.delete(node.path); + } else { + this.expandedFolders.add(node.path); + } + this.renderFolderTree(); + }; + } else { + const spacer = nodeEl.createEl("span", { text: " " }); + spacer.style.width = "1em"; + } + + // Folder name + const nameEl = nodeEl.createEl("span", { + text: node.name, + cls: "folder-tree-name", + }); + nameEl.style.flex = "1"; + + // Count + const countEl = nodeEl.createEl("span", { + text: `(${node.noteCount})`, + cls: "folder-tree-count", + }); + countEl.style.fontSize = "0.85em"; + countEl.style.color = "var(--text-muted)"; + + // Click handler + nodeEl.onclick = () => { + this.selectedFolderPath = node.path === "" ? null : node.path; + this.refresh(); + }; + + // Render children if expanded + if (node.children.length > 0 && this.expandedFolders.has(node.path)) { + for (const child of node.children) { + renderNode(child, depth + 1); + } + } + }; + + renderNode(this.folderTree); + } + + private renderResults(): void { + if (!this.resultsContainer) { + return; + } + + this.resultsContainer.empty(); + + // Get all entries + let entries = this.noteIndex.getAllEntries(); + + // Apply filters + const filterOptions: FilterOptions = { + search: this.searchQuery || undefined, + selectedFolderPath: this.selectedFolderPath, + typeSet: this.selectedTypes.size > 0 ? this.selectedTypes : undefined, + includeNoneType: this.includeNoneType, + }; + entries = applyFilters(entries, filterOptions); + + // Sort + const sortOptions: SortOptions = { + mode: this.sortMode, + searchQuery: this.searchQuery || undefined, + }; + entries = sortNotes(entries, sortOptions); + + // Group if needed + if (this.sortMode === "type") { + const groups = groupByType(entries, this.searchQuery); + this.renderGroupedResults(groups); + } else if (this.sortMode === "folder") { + const groups = groupByFolder(entries, this.searchQuery); + this.renderGroupedResults(groups); + } else { + this.renderFlatResults(entries); + } + } + + private renderFlatResults(entries: NoteIndexEntry[]): void { + if (!this.resultsContainer) { + return; + } + + if (entries.length === 0) { + const emptyEl = this.resultsContainer.createEl("div", { + text: "No notes found", + cls: "entity-picker-empty", + }); + emptyEl.style.padding = "1em"; + emptyEl.style.textAlign = "center"; + emptyEl.style.color = "var(--text-muted)"; + return; + } + + for (const entry of entries) { + const itemEl = this.resultsContainer.createEl("div", { + cls: "entity-picker-item", + }); + itemEl.style.padding = "0.5em"; + itemEl.style.cursor = "pointer"; + itemEl.style.borderBottom = "1px solid var(--background-modifier-border)"; + + itemEl.onmouseenter = () => { + itemEl.style.background = "var(--background-modifier-hover)"; + }; + itemEl.onmouseleave = () => { + itemEl.style.background = ""; + }; + + // Basename/Title + const nameEl = itemEl.createEl("div", { + text: entry.title || entry.basename, + cls: "entity-picker-item-name", + }); + nameEl.style.fontWeight = "500"; + + // Meta: type, folder + const metaEl = itemEl.createEl("div", { + cls: "entity-picker-item-meta", + }); + metaEl.style.fontSize = "0.85em"; + metaEl.style.color = "var(--text-muted)"; + metaEl.style.display = "flex"; + metaEl.style.gap = "1em"; + + if (entry.type) { + metaEl.createEl("span", { text: `Type: ${entry.type}` }); + } + if (entry.folder) { + metaEl.createEl("span", { text: `Folder: ${entry.folder}` }); + } + + // Click handler + itemEl.onclick = () => { + this.selectEntry(entry); + }; + } + } + + private renderGroupedResults(groups: GroupedNoteResult[]): void { + if (!this.resultsContainer) { + return; + } + + if (groups.length === 0) { + const emptyEl = this.resultsContainer.createEl("div", { + text: "No notes found", + cls: "entity-picker-empty", + }); + emptyEl.style.padding = "1em"; + emptyEl.style.textAlign = "center"; + emptyEl.style.color = "var(--text-muted)"; + return; + } + + for (const group of groups) { + // Group header + const groupHeader = this.resultsContainer.createEl("div", { + cls: "entity-picker-group-header", + }); + groupHeader.style.padding = "0.5em"; + groupHeader.style.fontWeight = "600"; + groupHeader.style.background = "var(--background-modifier-border)"; + groupHeader.style.borderTop = "1px solid var(--background-modifier-border)"; + groupHeader.style.borderBottom = "1px solid var(--background-modifier-border)"; + groupHeader.textContent = `${group.groupLabel} (${group.notes.length})`; + + // Group items + for (const entry of group.notes) { + const itemEl = this.resultsContainer.createEl("div", { + cls: "entity-picker-item", + }); + itemEl.style.padding = "0.5em"; + itemEl.style.cursor = "pointer"; + itemEl.style.borderBottom = "1px solid var(--background-modifier-border)"; + + itemEl.onmouseenter = () => { + itemEl.style.background = "var(--background-modifier-hover)"; + }; + itemEl.onmouseleave = () => { + itemEl.style.background = ""; + }; + + // Basename/Title + const nameEl = itemEl.createEl("div", { + text: entry.title || entry.basename, + cls: "entity-picker-item-name", + }); + nameEl.style.fontWeight = "500"; + + // Meta: folder (type is in group header) + if (entry.folder) { + const metaEl = itemEl.createEl("div", { + text: `Folder: ${entry.folder}`, + cls: "entity-picker-item-meta", + }); + metaEl.style.fontSize = "0.85em"; + metaEl.style.color = "var(--text-muted)"; + } + + // Click handler + itemEl.onclick = () => { + this.selectEntry(entry); + }; + } + } + } + + private renderTypeFilter(): void { + if (!this.typeFilterContainer) { + return; + } + + const allTypes = this.noteIndex.getAllTypes(); + + this.typeFilterContainer.empty(); + + // "(none)" option + const noneOption = this.typeFilterContainer.createEl("div", { + cls: "type-filter-option", + }); + noneOption.style.padding = "0.25em"; + noneOption.style.cursor = "pointer"; + noneOption.style.display = "flex"; + noneOption.style.alignItems = "center"; + noneOption.style.gap = "0.5em"; + + const noneCheckbox = noneOption.createEl("input", { + type: "checkbox", + }); + noneCheckbox.checked = this.includeNoneType; + noneCheckbox.onchange = () => { + this.includeNoneType = noneCheckbox.checked; + this.refresh(); + }; + + noneOption.createEl("label", { text: "(none)" }); + noneOption.onclick = () => { + noneCheckbox.checked = !noneCheckbox.checked; + this.includeNoneType = noneCheckbox.checked; + this.refresh(); + }; + + // Type options + for (const type of allTypes) { + const option = this.typeFilterContainer.createEl("div", { + cls: "type-filter-option", + }); + option.style.padding = "0.25em"; + option.style.cursor = "pointer"; + option.style.display = "flex"; + option.style.alignItems = "center"; + option.style.gap = "0.5em"; + + const checkbox = option.createEl("input", { + type: "checkbox", + }); + checkbox.checked = this.selectedTypes.has(type); + checkbox.onchange = () => { + if (checkbox.checked) { + this.selectedTypes.add(type); + } else { + this.selectedTypes.delete(type); + } + this.refresh(); + }; + + option.createEl("label", { text: type }); + option.onclick = () => { + checkbox.checked = !checkbox.checked; + if (checkbox.checked) { + this.selectedTypes.add(type); + } else { + this.selectedTypes.delete(type); + } + this.refresh(); + }; + } + } + + private toggleTypeFilter(): void { + if (!this.typeFilterContainer) { + return; + } + + const isVisible = this.typeFilterContainer.style.display !== "none"; + if (isVisible) { + this.typeFilterContainer.style.display = "none"; + } else { + // Position near the button + this.typeFilterContainer.style.display = "block"; + // Simple positioning (could be improved) + this.typeFilterContainer.style.top = "60px"; + this.typeFilterContainer.style.right = "20px"; + } + } + + private selectEntry(entry: NoteIndexEntry): void { + this.onSelect({ + basename: entry.basename, + path: entry.path, + }); + this.close(); + } +} diff --git a/src/ui/InterviewWizardModal.ts b/src/ui/InterviewWizardModal.ts index 59b9188..5a067e0 100644 --- a/src/ui/InterviewWizardModal.ts +++ b/src/ui/InterviewWizardModal.ts @@ -26,6 +26,9 @@ import { createMarkdownToolbar, } from "./markdownToolbar"; import { renderProfileToMarkdown, type RenderAnswers } from "../interview/renderer"; +import { NoteIndex } from "../entityPicker/noteIndex"; +import { EntityPickerModal, type EntityPickerResult } from "./EntityPickerModal"; +import { insertWikilinkIntoTextarea } from "../entityPicker/wikilink"; import { type LoopRuntimeState, createLoopState, @@ -59,6 +62,8 @@ export class InterviewWizardModal extends Modal { private currentInputValues: Map = new Map(); // Store preview mode state per step key private previewMode: Map = new Map(); + // Note index for entity picker (shared instance) + private noteIndex: NoteIndex | null = null; constructor( app: App, @@ -97,6 +102,9 @@ export class InterviewWizardModal extends Modal { this.state = createWizardState(profile); + // Initialize note index for entity picker + this.noteIndex = new NoteIndex(this.app); + // Validate flattened steps after creation const flat = flattenSteps(profile.steps); if (flat.length === 0 && profile.steps.length > 0) { @@ -218,6 +226,9 @@ export class InterviewWizardModal extends Modal { case "llm_dialog": this.renderLLMDialogStep(step, stepContentEl); break; + case "entity_picker": + this.renderEntityPickerStep(step, stepContentEl); + break; case "review": this.renderReviewStep(step, stepContentEl); break; @@ -435,6 +446,20 @@ export class InterviewWizardModal extends Modal { textEditorContainer.style.display = "block"; backToEditWrapper.style.display = "none"; } + }, + (app: App) => { + // Open entity picker modal + if (!this.noteIndex) { + new Notice("Note index not available"); + return; + } + new EntityPickerModal( + app, + this.noteIndex, + (result: EntityPickerResult) => { + insertWikilinkIntoTextarea(textarea, result.basename); + } + ).open(); } ); textEditorContainer.insertBefore(toolbar, textEditorContainer.firstChild); @@ -1343,6 +1368,20 @@ export class InterviewWizardModal extends Modal { textEditorContainer.style.display = "block"; backToEditWrapper.style.display = "none"; } + }, + (app: App) => { + // Open entity picker modal for loop nested step + if (!this.noteIndex) { + new Notice("Note index not available"); + return; + } + new EntityPickerModal( + app, + this.noteIndex, + (result: EntityPickerResult) => { + insertWikilinkIntoTextarea(textarea, result.basename); + } + ).open(); } ); textEditorContainer.insertBefore(itemToolbar, textEditorContainer.firstChild); @@ -2113,7 +2152,24 @@ export class InterviewWizardModal extends Modal { setTimeout(() => { const textarea = llmEditorContainer.querySelector("textarea"); if (textarea) { - const llmToolbar = createMarkdownToolbar(textarea); + const llmToolbar = createMarkdownToolbar( + textarea, + undefined, + (app: App) => { + // Open entity picker modal for LLM dialog + if (!this.noteIndex) { + new Notice("Note index not available"); + return; + } + new EntityPickerModal( + this.app, + this.noteIndex, + (result: EntityPickerResult) => { + insertWikilinkIntoTextarea(textarea, result.basename); + } + ).open(); + } + ); llmEditorContainer.insertBefore(llmToolbar, llmEditorContainer.firstChild); } }, 10); @@ -2322,6 +2378,90 @@ export class InterviewWizardModal extends Modal { } } + renderEntityPickerStep(step: InterviewStep, containerEl: HTMLElement): void { + if (step.type !== "entity_picker") return; + + const existingValue = this.state.collectedData.get(step.key) as string | undefined; + const selectedBasename = existingValue || ""; + + // Field container + const fieldContainer = containerEl.createEl("div", { + cls: "mindnet-field", + }); + + // Label + if (step.label) { + const labelEl = fieldContainer.createEl("div", { + cls: "mindnet-field__label", + text: step.label, + }); + } + + // Description/Prompt + if (step.prompt) { + const descEl = fieldContainer.createEl("div", { + cls: "mindnet-field__desc", + text: step.prompt, + }); + } + + // Input container + const inputContainer = fieldContainer.createEl("div", { + cls: "mindnet-field__input", + }); + inputContainer.style.width = "100%"; + inputContainer.style.display = "flex"; + inputContainer.style.gap = "0.5em"; + inputContainer.style.alignItems = "center"; + + // Readonly display of selected note + const displayEl = inputContainer.createEl("div", { + cls: "entity-picker-display", + }); + displayEl.style.flex = "1"; + displayEl.style.padding = "0.5em"; + displayEl.style.border = "1px solid var(--background-modifier-border)"; + displayEl.style.borderRadius = "4px"; + displayEl.style.background = "var(--background-secondary)"; + displayEl.style.minHeight = "2.5em"; + displayEl.style.display = "flex"; + displayEl.style.alignItems = "center"; + + if (selectedBasename) { + displayEl.textContent = `[[${selectedBasename}]]`; + displayEl.style.color = "var(--text-normal)"; + } else { + displayEl.textContent = "(No note selected)"; + displayEl.style.color = "var(--text-muted)"; + displayEl.style.fontStyle = "italic"; + } + + // Pick button + const pickBtn = inputContainer.createEl("button", { + text: selectedBasename ? "Change…" : "Pick note…", + cls: "mod-cta", + }); + pickBtn.style.flexShrink = "0"; + pickBtn.onclick = () => { + if (!this.noteIndex) { + new Notice("Note index not available"); + return; + } + + new EntityPickerModal( + this.app, + this.noteIndex, + (result: EntityPickerResult) => { + // Store basename in collected data + this.state.collectedData.set(step.key, result.basename); + // Optionally store path for future use + this.state.collectedData.set(`${step.key}_path`, result.path); + this.renderStep(); + } + ).open(); + }; + } + async applyPatches(): Promise { console.log("=== APPLY PATCHES ===", { patchCount: this.state.patches.length, diff --git a/src/ui/markdownToolbar.ts b/src/ui/markdownToolbar.ts index e9cf403..a8d1e47 100644 --- a/src/ui/markdownToolbar.ts +++ b/src/ui/markdownToolbar.ts @@ -358,7 +358,8 @@ function applyToTextarea( */ export function createMarkdownToolbar( textarea: HTMLTextAreaElement, - onTogglePreview?: () => void + onTogglePreview?: () => void, + onPickNote?: (app: any) => void ): HTMLElement { const toolbar = document.createElement("div"); toolbar.className = "markdown-toolbar"; @@ -465,6 +466,20 @@ export function createMarkdownToolbar( }); toolbar.appendChild(linkBtn); + // Pick note button (if callback provided) + if (onPickNote) { + const pickNoteBtn = createToolbarButton("📎", "Pick note…", () => { + // Get app from window (Obsidian exposes it globally) + const app = (window as any).app; + if (app && onPickNote) { + onPickNote(app); + } else { + console.warn("App not available for entity picker"); + } + }); + toolbar.appendChild(pickNoteBtn); + } + // Preview toggle if (onTogglePreview) { const previewBtn = createToolbarButton("👁️", "Toggle Preview", () => {