Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions src/gui/suggesters/FileIndex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ import type { App, TFile, Vault, MetadataCache, Workspace } from 'obsidian';
// Test-specific subclass that allows resetting the singleton
class TestableFileIndex extends FileIndex {
static reset(): void {
if (TestableFileIndex.instance) {
if (FileIndex.instance) {
// Clear any pending timeouts
if ((TestableFileIndex.instance as any).reindexTimeout !== null) {
clearTimeout((TestableFileIndex.instance as any).reindexTimeout);
if ((FileIndex.instance as any).reindexTimeout !== null) {
clearTimeout((FileIndex.instance as any).reindexTimeout);
}
if ((TestableFileIndex.instance as any).fuseUpdateTimeout !== null) {
clearTimeout((TestableFileIndex.instance as any).fuseUpdateTimeout);
if ((FileIndex.instance as any).fuseUpdateTimeout !== null) {
clearTimeout((FileIndex.instance as any).fuseUpdateTimeout);
}
// Clear the instance
TestableFileIndex.instance = null as any;
}
// Clear the instance
FileIndex.instance = null as any;
}
}

Expand Down Expand Up @@ -189,9 +189,10 @@ describe('FileIndex', () => {
}));

// Initial index – ensure all batched timers run so both files are indexed
await freshIndex.ensureIndexed();
const indexPromise = freshIndex.ensureIndexed();
// Flush any pending timers (including 0-ms ones) used inside performReindex()
await vi.runAllTimersAsync();
await indexPromise;
expect(freshIndex.getIndexedFileCount()).toBeGreaterThanOrEqual(1);

// Spy on the methods - use proper type assertion
Expand Down Expand Up @@ -340,6 +341,28 @@ describe('FileIndex', () => {
});

describe('search functionality', () => {
it('should match normalized query against decomposed filenames', async () => {
const nfcName = 'Rücken-Fit';
const nfdName = nfcName.normalize('NFD');
const files = [
{
path: `${nfdName}.md`,
basename: nfdName,
extension: 'md',
parent: { path: '' },
stat: { mtime: Date.now() }
}
] as TFile[];

(mockApp.vault.getMarkdownFiles as any).mockReturnValue(files);
mockApp.metadataCache.getFileCache = vi.fn(() => ({}));

await fileIndex.ensureIndexed();
const results = fileIndex.search('Rü', {}, 10);

expect(results.some(result => result.file.basename === nfdName)).toBe(true);
});

it.skip('should return exact matches first', async () => {
const files = [
{ path: 'test.md', basename: 'test' },
Expand Down
98 changes: 65 additions & 33 deletions src/gui/suggesters/FileIndex.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { App, Plugin } from "obsidian";
import { TFile } from "obsidian";
import Fuse from "fuse.js";
import { sanitizeHeading } from "./utils";
import { normalizeForFuse, normalizeForSearch, sanitizeHeading } from "./utils";

export interface IndexedFile {
path: string;
Expand Down Expand Up @@ -69,6 +69,29 @@ const FUSE_UPDATE_DEBOUNCE_MS = 100;
// Regex to test if a character is alphanumeric (used for word boundary detection)
const ALPHANUMERIC_REGEX = /\w/;

const normalizeFuseValue = (value: unknown): string | string[] => {
if (typeof value === "string") return normalizeForFuse(value);
if (Array.isArray(value)) {
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => normalizeForFuse(entry));
}
return "";
};

const resolvePath = (obj: IndexedFile, path: string | string[]): unknown => {
if (Array.isArray(path)) {
return path.reduce((acc: any, key) => acc?.[key], obj as any);
}
if (path.includes(".")) {
return path.split(".").reduce((acc: any, key) => acc?.[key], obj as any);
}
return (obj as any)[path];
};

const getFuseValue = (obj: IndexedFile, path: string | string[]): string | string[] =>
normalizeFuseValue(resolvePath(obj, path));

// Configurable search ranking weights
export const SearchWeights = {
base: {
Expand Down Expand Up @@ -131,7 +154,8 @@ export class FileIndex {
ignoreLocation: true,
findAllMatches: true,
shouldSort: false, // We'll handle sorting ourselves
includeMatches: true // Include match information to detect alias hits
includeMatches: true, // Include match information to detect alias hits
getFn: getFuseValue
};

this.fuseStrict = new Fuse<IndexedFile>([], {
Expand Down Expand Up @@ -459,7 +483,8 @@ export class FileIndex {

search(query: string, context: SearchContext = {}, limit = 50): SearchResult[] {
const results: SearchResult[] = [];
const queryLower = query.toLowerCase();
const queryLower = normalizeForSearch(query);
const fuseQuery = normalizeForFuse(query);

// Handle global heading search when query contains '#'
if (query.includes('#')) {
Expand All @@ -474,7 +499,7 @@ export class FileIndex {

// 1. Exact matches (basename and aliases) - Tier 0
for (const file of allFiles) {
if (file.basename.toLowerCase() === queryLower) {
if (normalizeForSearch(file.basename) === queryLower) {
results.push({
file,
score: this.calculateScore(file, query, context, this.effectiveWeights.base.basenameExact, 'exact'),
Expand All @@ -491,7 +516,7 @@ export class FileIndex {
if (addedPaths.has(file.path)) continue;

for (const alias of file.aliases) {
if (alias.toLowerCase() === queryLower) {
if (normalizeForSearch(alias) === queryLower) {
results.push({
file,
score: this.calculateScore(file, query, context, this.effectiveWeights.base.aliasExact, 'alias'),
Expand All @@ -506,8 +531,9 @@ export class FileIndex {

// 1.5. Prefix matches (basename) - Tier 1
for (const file of allFiles) {
if (file.basename.toLowerCase().startsWith(queryLower) &&
file.basename.toLowerCase() !== queryLower && // not exact (already added)
const basenameLower = normalizeForSearch(file.basename);
if (basenameLower.startsWith(queryLower) &&
basenameLower !== queryLower && // not exact (already added)
!addedPaths.has(file.path)) {
results.push({
file,
Expand All @@ -522,8 +548,9 @@ export class FileIndex {
// 2. Prefix alias matches - Tier 1
for (const file of allFiles) {
for (const alias of file.aliases) {
if (alias.toLowerCase().startsWith(queryLower) &&
alias.toLowerCase() !== queryLower && // not exact (already added)
const aliasLower = normalizeForSearch(alias);
if (aliasLower.startsWith(queryLower) &&
aliasLower !== queryLower && // not exact (already added)
!addedPaths.has(file.path)) {
results.push({
file,
Expand All @@ -540,10 +567,11 @@ export class FileIndex {
for (const file of allFiles) {
if (addedPaths.has(file.path)) continue;

const idx = file.basename.toLowerCase().indexOf(queryLower);
const basenameLower = normalizeForSearch(file.basename);
const idx = basenameLower.indexOf(queryLower);
if (idx > 0) { // not at start (that would be prefix)
// Check if match starts at word boundary
const charBefore = file.basename[idx - 1];
const charBefore = basenameLower[idx - 1];
if (!ALPHANUMERIC_REGEX.test(charBefore)) { // Previous char is not alphanumeric
results.push({
file,
Expand All @@ -557,11 +585,11 @@ export class FileIndex {
}

// 3. Fuzzy search with adaptive threshold - Tier 3 and below
let fuseResults = this.fuseStrict.search(query, { limit: limit * 2 });
let fuseResults = this.fuseStrict.search(fuseQuery, { limit: limit * 2 });

// Relax threshold if we have too few results
if (fuseResults.length < this.effectiveWeights.thresholds.fuzzyRelaxCount) {
fuseResults = this.fuseRelaxed.search(query, { limit: limit * 2 });
fuseResults = this.fuseRelaxed.search(fuseQuery, { limit: limit * 2 });
}

for (const result of fuseResults) {
Expand Down Expand Up @@ -599,7 +627,7 @@ export class FileIndex {
for (const unresolvedLink of this.unresolvedLinks) {
if (unresolvedCount >= unresolvedLimit) break;

if (unresolvedLink.toLowerCase().includes(queryLower)) {
if (normalizeForSearch(unresolvedLink).includes(queryLower)) {
results.push({
file: {
path: unresolvedLink,
Expand Down Expand Up @@ -654,14 +682,14 @@ export class FileIndex {
}

// Length penalty - calculate first as it's used by alias penalty
const queryLower = query.toLowerCase();
const queryLower = normalizeForSearch(query);
let titleLength = file.basename.length;

// For alias matches, find the actual matched alias to get correct length
if (matchType === 'alias' && file.aliases.length > 0) {
// Find which alias was matched
const matchedAlias = file.aliases.find(alias =>
alias.toLowerCase().includes(queryLower)
normalizeForSearch(alias).includes(queryLower)
);
if (matchedAlias) {
titleLength = matchedAlias.length;
Expand All @@ -683,15 +711,15 @@ export class FileIndex {
}

// Position bonus - earlier matches are better
let textToSearch = file.basename.toLowerCase();
let textToSearch = normalizeForSearch(file.basename);

// For alias matches, find the actual matched alias for position calculation
if (matchType === 'alias' && file.aliases.length > 0) {
const matchedAlias = file.aliases.find(alias =>
alias.toLowerCase().includes(queryLower)
normalizeForSearch(alias).includes(queryLower)
);
if (matchedAlias) {
textToSearch = matchedAlias.toLowerCase();
textToSearch = normalizeForSearch(matchedAlias);
}
}

Expand All @@ -706,7 +734,7 @@ export class FileIndex {

private searchWithHeadings(query: string, context: SearchContext, limit: number): SearchResult[] {
const [filePart, headingPartRaw] = query.split('#');
const headingPart = headingPartRaw.toLowerCase();
const headingPart = normalizeForSearch(headingPartRaw ?? "");
const results: SearchResult[] = [];

// Prevent infinite recursion by doing a simple search if no file part
Expand All @@ -722,7 +750,7 @@ export class FileIndex {
for (const heading of file.headings) {
if (resultCount >= maxResults) break;

if (heading.toLowerCase().includes(headingPart)) {
if (normalizeForSearch(heading).includes(headingPart)) {
results.push({
file,
score: this.calculateScore(file, query, context, 0.1),
Expand All @@ -740,7 +768,7 @@ export class FileIndex {

for (const fileResult of fileResults) {
for (const heading of fileResult.file.headings) {
if (heading.toLowerCase().includes(headingPart)) {
if (normalizeForSearch(heading).includes(headingPart)) {
results.push({
file: fileResult.file,
score: fileResult.score + 0.05,
Expand All @@ -760,15 +788,16 @@ export class FileIndex {
private searchFiles(query: string, context: SearchContext, limit: number): SearchResult[] {
// Direct file search without heading handling to avoid recursion
const results: SearchResult[] = [];
const queryLower = query.toLowerCase();
const queryLower = normalizeForSearch(query);
const fuseQuery = normalizeForFuse(query);
const addedPaths = new Set<string>();

// Pre-create array from fileMap for better performance
const allFiles = Array.from(this.fileMap.values());

// 1. Exact matches (basename and aliases) - Tier 0
for (const file of allFiles) {
if (file.basename.toLowerCase() === queryLower) {
if (normalizeForSearch(file.basename) === queryLower) {
results.push({
file,
score: this.calculateScore(file, query, context, -1000, 'exact'),
Expand All @@ -785,7 +814,7 @@ export class FileIndex {
if (addedPaths.has(file.path)) continue;

for (const alias of file.aliases) {
if (alias.toLowerCase() === queryLower) {
if (normalizeForSearch(alias) === queryLower) {
results.push({
file,
score: this.calculateScore(file, query, context, this.effectiveWeights.base.aliasExact, 'alias'),
Expand All @@ -800,8 +829,9 @@ export class FileIndex {

// 1.5. Prefix matches (basename) - Tier 1
for (const file of allFiles) {
if (file.basename.toLowerCase().startsWith(queryLower) &&
file.basename.toLowerCase() !== queryLower &&
const basenameLower = normalizeForSearch(file.basename);
if (basenameLower.startsWith(queryLower) &&
basenameLower !== queryLower &&
!addedPaths.has(file.path)) {
results.push({
file,
Expand All @@ -816,8 +846,9 @@ export class FileIndex {
// 2. Prefix alias matches - Tier 1
for (const file of allFiles) {
for (const alias of file.aliases) {
if (alias.toLowerCase().startsWith(queryLower) &&
alias.toLowerCase() !== queryLower &&
const aliasLower = normalizeForSearch(alias);
if (aliasLower.startsWith(queryLower) &&
aliasLower !== queryLower &&
!addedPaths.has(file.path)) {
results.push({
file,
Expand All @@ -834,10 +865,11 @@ export class FileIndex {
for (const file of allFiles) {
if (addedPaths.has(file.path)) continue;

const idx = file.basename.toLowerCase().indexOf(queryLower);
const basenameLower = normalizeForSearch(file.basename);
const idx = basenameLower.indexOf(queryLower);
if (idx > 0) { // not at start (that would be prefix)
// Check if match starts at word boundary
const charBefore = file.basename[idx - 1];
const charBefore = basenameLower[idx - 1];
if (!ALPHANUMERIC_REGEX.test(charBefore)) { // Previous char is not alphanumeric
results.push({
file,
Expand All @@ -851,9 +883,9 @@ export class FileIndex {
}

// 3. Fuzzy search - Tier 3 and below
let fuseResults = this.fuseStrict.search(query, { limit: limit * 2 });
let fuseResults = this.fuseStrict.search(fuseQuery, { limit: limit * 2 });
if (fuseResults.length < this.effectiveWeights.thresholds.fuzzyRelaxCount) {
fuseResults = this.fuseRelaxed.search(query, { limit: limit * 2 });
fuseResults = this.fuseRelaxed.search(fuseQuery, { limit: limit * 2 });
}

for (const result of fuseResults) {
Expand Down
14 changes: 9 additions & 5 deletions src/gui/suggesters/fileSuggester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { App } from "obsidian";
import { TFile } from "obsidian";
import { FILE_LINK_REGEX } from "../../constants";
import { FileIndex, type SearchResult, type SearchContext, type IndexedFile } from "./FileIndex";
import { normalizeForSearch } from "./utils";
import QuickAdd from "../../main";

interface HTMLElementWithTooltipCleanup extends HTMLElement {
Expand Down Expand Up @@ -125,6 +126,7 @@ export class FileSuggester extends TextInputSuggest<SearchResult> {
private getHeadingSuggestions(input: string): SearchResult[] {
const [fileName, headingQuery] = input.split('#');
const noFileSpecified = fileName.trim() === '';
const headingQueryNormalized = normalizeForSearch(headingQuery ?? "");

// Determine candidate files based on whether file part was specified
let candidateFiles: IndexedFile[] = [];
Expand All @@ -148,9 +150,9 @@ export class FileSuggester extends TextInputSuggest<SearchResult> {
for (const file of candidateFiles) {
const headings = this.fileIndex.getHeadings(file);

const filteredHeadings = headings
.filter(h => headingQuery === '' || h.toLowerCase().includes(headingQuery.toLowerCase()))
.slice(0, 20);
const filteredHeadings = headings
.filter(h => headingQuery === '' || normalizeForSearch(h).includes(headingQueryNormalized))
.slice(0, 20);

for (const heading of filteredHeadings) {
results.push({
Expand Down Expand Up @@ -213,10 +215,11 @@ export class FileSuggester extends TextInputSuggest<SearchResult> {
// Get all files, not just markdown
const allFiles = this.app.vault.getFiles();
const attachmentExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'pdf', 'mp4', 'webm', 'mov', 'canvas'];
const queryLower = normalizeForSearch(query);

const attachmentFiles = allFiles.filter(file =>
attachmentExtensions.includes(file.extension.toLowerCase()) &&
(query === '' || file.basename.toLowerCase().includes(query.toLowerCase()))
(query === '' || normalizeForSearch(file.basename).includes(queryLower))
);

return attachmentFiles
Expand Down Expand Up @@ -269,7 +272,8 @@ export class FileSuggester extends TextInputSuggest<SearchResult> {
const headingQuery = this.lastInput.includes('#')
? this.lastInput.split('#')[1]
: '';
if (headingQuery && heading.toLowerCase().includes(headingQuery.toLowerCase())) {
const headingQueryNormalized = normalizeForSearch(headingQuery ?? "");
if (headingQuery && normalizeForSearch(heading).includes(headingQueryNormalized)) {
const tempEl = document.createElement('span');
this.renderMatch(tempEl, heading, headingQuery);
mainText = tempEl.innerHTML;
Expand Down
Loading