NotePicker - 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 06:28:36 +01:00
parent b5f2c8fc67
commit 78e8216ab9
13 changed files with 1651 additions and 4 deletions

266
src/entityPicker/filters.ts Normal file
View File

@ -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<string, NoteIndexEntry[]>();
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<string, NoteIndexEntry[]>();
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;
}

View File

@ -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<string, FolderTreeNode>();
nodeMap.set("", root);
// Count notes per folder
const folderCounts = new Map<string, number>();
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<string> {
const paths = new Set<string>();
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;
}

View File

@ -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<string, NoteIndexEntry> = 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<string, NoteIndexEntry>();
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<string>();
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);
}
}
}
}

48
src/entityPicker/types.ts Normal file
View File

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

View File

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

View File

@ -377,6 +377,33 @@ function parseStep(raw: Record<string, unknown>): InterviewStep | null {
return step; 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") { if (type === "review") {
const key = getKey(); const key = getKey();
if (!key) { if (!key) {

View File

@ -3,7 +3,7 @@
* Converts collected answers into markdown output based on profile configuration. * 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 { export interface RenderAnswers {
collectedData: Map<string, unknown>; collectedData: Map<string, unknown>;
@ -44,6 +44,8 @@ function renderStep(step: InterviewStep, answers: RenderAnswers): string | null
return null; return null;
case "loop": case "loop":
return renderLoop(step, answers); return renderLoop(step, answers);
case "entity_picker":
return renderEntityPicker(step, answers);
case "instruction": case "instruction":
case "llm_dialog": case "llm_dialog":
case "review": case "review":
@ -122,6 +124,32 @@ function renderCaptureTextLine(step: CaptureTextLineStep, answers: RenderAnswers
return headingPrefix + text; 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. * Render capture_frontmatter step.
* Note: This is typically handled separately, but included for completeness. * Note: This is typically handled separately, but included for completeness.

View File

@ -24,7 +24,8 @@ export type InterviewStep =
| CaptureTextStep | CaptureTextStep
| CaptureTextLineStep | CaptureTextLineStep
| InstructionStep | InstructionStep
| ReviewStep; | ReviewStep
| EntityPickerStep;
export interface LoopStep { export interface LoopStep {
type: "loop"; type: "loop";
@ -96,6 +97,15 @@ export interface InstructionStep {
content: string; 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 { export interface ReviewStep {
type: "review"; type: "review";
key: string; key: string;

View File

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

View File

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

558
src/ui/EntityPickerModal.ts Normal file
View File

@ -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<string> = 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<string> = 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();
}
}

View File

@ -26,6 +26,9 @@ import {
createMarkdownToolbar, createMarkdownToolbar,
} from "./markdownToolbar"; } from "./markdownToolbar";
import { renderProfileToMarkdown, type RenderAnswers } from "../interview/renderer"; import { renderProfileToMarkdown, type RenderAnswers } from "../interview/renderer";
import { NoteIndex } from "../entityPicker/noteIndex";
import { EntityPickerModal, type EntityPickerResult } from "./EntityPickerModal";
import { insertWikilinkIntoTextarea } from "../entityPicker/wikilink";
import { import {
type LoopRuntimeState, type LoopRuntimeState,
createLoopState, createLoopState,
@ -59,6 +62,8 @@ export class InterviewWizardModal extends Modal {
private currentInputValues: Map<string, string> = new Map(); private currentInputValues: Map<string, string> = new Map();
// Store preview mode state per step key // Store preview mode state per step key
private previewMode: Map<string, boolean> = new Map(); private previewMode: Map<string, boolean> = new Map();
// Note index for entity picker (shared instance)
private noteIndex: NoteIndex | null = null;
constructor( constructor(
app: App, app: App,
@ -97,6 +102,9 @@ export class InterviewWizardModal extends Modal {
this.state = createWizardState(profile); this.state = createWizardState(profile);
// Initialize note index for entity picker
this.noteIndex = new NoteIndex(this.app);
// Validate flattened steps after creation // Validate flattened steps after creation
const flat = flattenSteps(profile.steps); const flat = flattenSteps(profile.steps);
if (flat.length === 0 && profile.steps.length > 0) { if (flat.length === 0 && profile.steps.length > 0) {
@ -218,6 +226,9 @@ export class InterviewWizardModal extends Modal {
case "llm_dialog": case "llm_dialog":
this.renderLLMDialogStep(step, stepContentEl); this.renderLLMDialogStep(step, stepContentEl);
break; break;
case "entity_picker":
this.renderEntityPickerStep(step, stepContentEl);
break;
case "review": case "review":
this.renderReviewStep(step, stepContentEl); this.renderReviewStep(step, stepContentEl);
break; break;
@ -435,6 +446,20 @@ export class InterviewWizardModal extends Modal {
textEditorContainer.style.display = "block"; textEditorContainer.style.display = "block";
backToEditWrapper.style.display = "none"; 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); textEditorContainer.insertBefore(toolbar, textEditorContainer.firstChild);
@ -1343,6 +1368,20 @@ export class InterviewWizardModal extends Modal {
textEditorContainer.style.display = "block"; textEditorContainer.style.display = "block";
backToEditWrapper.style.display = "none"; 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); textEditorContainer.insertBefore(itemToolbar, textEditorContainer.firstChild);
@ -2113,7 +2152,24 @@ export class InterviewWizardModal extends Modal {
setTimeout(() => { setTimeout(() => {
const textarea = llmEditorContainer.querySelector("textarea"); const textarea = llmEditorContainer.querySelector("textarea");
if (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); llmEditorContainer.insertBefore(llmToolbar, llmEditorContainer.firstChild);
} }
}, 10); }, 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<void> { async applyPatches(): Promise<void> {
console.log("=== APPLY PATCHES ===", { console.log("=== APPLY PATCHES ===", {
patchCount: this.state.patches.length, patchCount: this.state.patches.length,

View File

@ -358,7 +358,8 @@ function applyToTextarea(
*/ */
export function createMarkdownToolbar( export function createMarkdownToolbar(
textarea: HTMLTextAreaElement, textarea: HTMLTextAreaElement,
onTogglePreview?: () => void onTogglePreview?: () => void,
onPickNote?: (app: any) => void
): HTMLElement { ): HTMLElement {
const toolbar = document.createElement("div"); const toolbar = document.createElement("div");
toolbar.className = "markdown-toolbar"; toolbar.className = "markdown-toolbar";
@ -465,6 +466,20 @@ export function createMarkdownToolbar(
}); });
toolbar.appendChild(linkBtn); 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 // Preview toggle
if (onTogglePreview) { if (onTogglePreview) {
const previewBtn = createToolbarButton("👁️", "Toggle Preview", () => { const previewBtn = createToolbarButton("👁️", "Toggle Preview", () => {