NotePicker - V1
This commit is contained in:
parent
b5f2c8fc67
commit
78e8216ab9
266
src/entityPicker/filters.ts
Normal file
266
src/entityPicker/filters.ts
Normal 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;
|
||||
}
|
||||
125
src/entityPicker/folderTree.ts
Normal file
125
src/entityPicker/folderTree.ts
Normal 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;
|
||||
}
|
||||
162
src/entityPicker/noteIndex.ts
Normal file
162
src/entityPicker/noteIndex.ts
Normal 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
48
src/entityPicker/types.ts
Normal 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
|
||||
}
|
||||
61
src/entityPicker/wikilink.ts
Normal file
61
src/entityPicker/wikilink.ts
Normal 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 }));
|
||||
}
|
||||
|
|
@ -377,6 +377,33 @@ function parseStep(raw: Record<string, unknown>): 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) {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
151
src/tests/entityPicker/filters.test.ts
Normal file
151
src/tests/entityPicker/filters.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
src/tests/entityPicker/wikilink.test.ts
Normal file
56
src/tests/entityPicker/wikilink.test.ts
Normal 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
558
src/ui/EntityPickerModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string> = new Map();
|
||||
// Store preview mode state per step key
|
||||
private previewMode: Map<string, boolean> = 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<void> {
|
||||
console.log("=== APPLY PATCHES ===", {
|
||||
patchCount: this.state.patches.length,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user