Skip to content

Commit 10c0402

Browse files
authored
fix: normalize unicode in file suggestions (#1046)
* fix: normalize unicode in file suggestions * fix: correct fuse normalization typing * fix: normalize block id filtering * chore: align fuse getFn typing * refactor: pre-normalize file index fields
1 parent 4c8cfe9 commit 10c0402

File tree

4 files changed

+184
-124
lines changed

4 files changed

+184
-124
lines changed

src/gui/suggesters/FileIndex.test.ts

Lines changed: 72 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { FileIndex } from './FileIndex';
3+
import { normalizeForSearch } from './utils';
34
import type { App, TFile, Vault, MetadataCache, Workspace } from 'obsidian';
45

56
// Test-specific subclass that allows resetting the singleton
67
class TestableFileIndex extends FileIndex {
78
static reset(): void {
8-
if (TestableFileIndex.instance) {
9+
if (FileIndex.instance) {
910
// Clear any pending timeouts
10-
if ((TestableFileIndex.instance as any).reindexTimeout !== null) {
11-
clearTimeout((TestableFileIndex.instance as any).reindexTimeout);
11+
if ((FileIndex.instance as any).reindexTimeout !== null) {
12+
clearTimeout((FileIndex.instance as any).reindexTimeout);
1213
}
13-
if ((TestableFileIndex.instance as any).fuseUpdateTimeout !== null) {
14-
clearTimeout((TestableFileIndex.instance as any).fuseUpdateTimeout);
14+
if ((FileIndex.instance as any).fuseUpdateTimeout !== null) {
15+
clearTimeout((FileIndex.instance as any).fuseUpdateTimeout);
1516
}
16-
// Clear the instance
17-
TestableFileIndex.instance = null as any;
1817
}
18+
// Clear the instance
19+
FileIndex.instance = null as any;
1920
}
2021
}
2122

@@ -49,6 +50,32 @@ const createMockApp = (): App => {
4950
} as App;
5051
};
5152

53+
const createMockIndexedFile = (overrides: {
54+
path: string;
55+
basename: string;
56+
aliases?: string[];
57+
headings?: string[];
58+
blockIds?: string[];
59+
tags?: string[];
60+
modified?: number;
61+
folder?: string;
62+
}) => {
63+
const aliases = overrides.aliases ?? [];
64+
return {
65+
path: overrides.path,
66+
pathNormalized: normalizeForSearch(overrides.path),
67+
basename: overrides.basename,
68+
basenameNormalized: normalizeForSearch(overrides.basename),
69+
aliases,
70+
aliasesNormalized: aliases.map((alias) => normalizeForSearch(alias)),
71+
headings: overrides.headings ?? [],
72+
blockIds: overrides.blockIds ?? [],
73+
tags: overrides.tags ?? [],
74+
modified: overrides.modified ?? Date.now(),
75+
folder: overrides.folder ?? ''
76+
};
77+
};
78+
5279
describe('FileIndex', () => {
5380
let mockApp: App;
5481
let fileIndex: FileIndex;
@@ -66,16 +93,11 @@ describe('FileIndex', () => {
6693

6794
describe('scoring system', () => {
6895
it('should boost same-folder files', () => {
69-
const mockFile = {
96+
const mockFile = createMockIndexedFile({
7097
path: 'folder/test.md',
7198
basename: 'test',
72-
aliases: [],
73-
headings: [],
74-
blockIds: [],
75-
tags: [],
76-
modified: Date.now(),
7799
folder: 'folder'
78-
};
100+
});
79101

80102
const context = { currentFolder: 'folder' };
81103
const score = (fileIndex as any).calculateScore(mockFile, 'test', context, 0.5);
@@ -84,33 +106,23 @@ describe('FileIndex', () => {
84106
});
85107

86108
it('should penalize alias match types', () => {
87-
const mockFile = {
109+
const mockFile = createMockIndexedFile({
88110
path: 'test.md',
89111
basename: 'test',
90-
aliases: ['exact-match'],
91-
headings: [],
92-
blockIds: [],
93-
tags: [],
94-
modified: Date.now(),
95-
folder: ''
96-
};
112+
aliases: ['exact-match']
113+
});
97114

98115
const score = (fileIndex as any).calculateScore(mockFile, 'exact-match', {}, 0.5, 'alias');
99116

100117
expect(score).toBeGreaterThan(0.5); // Should be penalized (higher score = worse ranking)
101118
});
102119

103120
it('should rank basename matches better than alias matches', () => {
104-
const mockFile = {
121+
const mockFile = createMockIndexedFile({
105122
path: 'test.md',
106123
basename: 'test',
107-
aliases: ['my-alias'],
108-
headings: [],
109-
blockIds: [],
110-
tags: [],
111-
modified: Date.now(),
112-
folder: ''
113-
};
124+
aliases: ['my-alias']
125+
});
114126

115127
const aliasScore = (fileIndex as any).calculateScore(mockFile, 'my-alias', {}, 0.5, 'alias');
116128
const basenameScore = (fileIndex as any).calculateScore(mockFile, 'test', {}, 0.5, 'exact');
@@ -119,27 +131,17 @@ describe('FileIndex', () => {
119131
});
120132

121133
it('should boost files with tag overlap', () => {
122-
const currentFile = {
134+
const currentFile = createMockIndexedFile({
123135
path: 'current.md',
124136
basename: 'current',
125-
aliases: [],
126-
headings: [],
127-
blockIds: [],
128-
tags: ['#shared-tag'],
129-
modified: Date.now(),
130-
folder: ''
131-
};
137+
tags: ['#shared-tag']
138+
});
132139

133-
const testFile = {
140+
const testFile = createMockIndexedFile({
134141
path: 'test.md',
135142
basename: 'test',
136-
aliases: [],
137-
headings: [],
138-
blockIds: [],
139-
tags: ['#shared-tag', '#other-tag'],
140-
modified: Date.now(),
141-
folder: ''
142-
};
143+
tags: ['#shared-tag', '#other-tag']
144+
});
143145

144146
// Simulate current file in map
145147
(fileIndex as any).fileMap.set('current.md', currentFile);
@@ -189,9 +191,10 @@ describe('FileIndex', () => {
189191
}));
190192

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

197200
// Spy on the methods - use proper type assertion
@@ -340,6 +343,28 @@ describe('FileIndex', () => {
340343
});
341344

342345
describe('search functionality', () => {
346+
it('should match normalized query against decomposed filenames', async () => {
347+
const nfcName = 'Rücken-Fit';
348+
const nfdName = nfcName.normalize('NFD');
349+
const files = [
350+
{
351+
path: `${nfdName}.md`,
352+
basename: nfdName,
353+
extension: 'md',
354+
parent: { path: '' },
355+
stat: { mtime: Date.now() }
356+
}
357+
] as TFile[];
358+
359+
(mockApp.vault.getMarkdownFiles as any).mockReturnValue(files);
360+
mockApp.metadataCache.getFileCache = vi.fn(() => ({}));
361+
362+
await fileIndex.ensureIndexed();
363+
const results = fileIndex.search('Rü', {}, 10);
364+
365+
expect(results.some(result => result.file.basename === nfdName)).toBe(true);
366+
});
367+
343368
it.skip('should return exact matches first', async () => {
344369
const files = [
345370
{ path: 'test.md', basename: 'test' },

0 commit comments

Comments
 (0)