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;
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user