From 265c5878c858bb4f98f417e533d8a1e7bffe3056 Mon Sep 17 00:00:00 2001 From: itzabdoull Date: Tue, 30 Jun 2026 09:14:09 +0100 Subject: [PATCH 1/2] fix: support diacritic-insensitive search indexing (#629) --- src/services/searchIndex.ts | 13 ++-- src/utils/stringUtils.ts | 8 +++ tests/services/searchIndex.test.ts | 107 +++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 src/utils/stringUtils.ts create mode 100644 tests/services/searchIndex.test.ts diff --git a/src/services/searchIndex.ts b/src/services/searchIndex.ts index a6b57307..7ed21eb6 100644 --- a/src/services/searchIndex.ts +++ b/src/services/searchIndex.ts @@ -4,6 +4,7 @@ import { FilterValues } from '../components/mobile/FilterSheet'; import { SearchResultItem } from '../components/mobile/SearchResultCard'; import { Course } from '../types/course'; import { appLogger } from '../utils/logger'; +import { normalizeText } from '../utils/stringUtils'; import { buildTrie, Trie } from '../utils/trie'; const INDEX_STORAGE_KEY = '@teachlink_search_index'; @@ -168,24 +169,24 @@ export function buildSearchIndex(courses: Course[]): PersistedSearchIndex { // Suggestions: full title and category phrases as well as individual words. suggestionSet.add(course.title); suggestionSet.add(course.category); - for (const t of tokenize(course.title)) suggestionSet.add(t); + for (const t of tokenize(normalizeText(course.title))) suggestionSet.add(t); // Title - const titleTokens = tokenize(course.title); + const titleTokens = tokenize(normalizeText(course.title)); addWeightedTokens(entries, titleTokens, course.id, FIELD_WEIGHTS.title); // Category - for (const token of tokenize(course.category)) { + for (const token of tokenize(normalizeText(course.category))) { addEntry(entries, token, course.id, FIELD_WEIGHTS.category); } // Instructor name - for (const token of tokenize(course.instructor.name)) { + for (const token of tokenize(normalizeText(course.instructor.name))) { addEntry(entries, token, course.id, FIELD_WEIGHTS.instructor); } // Description (length-capped) - const descTokens = tokenize(course.description, MAX_DESC_TOKENS); + const descTokens = tokenize(normalizeText(course.description), MAX_DESC_TOKENS); addWeightedTokens(entries, descTokens, course.id, FIELD_WEIGHTS.description); } @@ -274,7 +275,7 @@ export class SearchIndexService { search(query: string, filters: FilterValues = {}, maxResults = 50): SearchResultItem[] { if (!this.index || !this.tokenTrie) return []; - const tokens = tokenize(query); + const tokens = tokenize(normalizeText(query)); if (tokens.length === 0) return []; const scoreMap = new Map(); diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts new file mode 100644 index 00000000..31deda86 --- /dev/null +++ b/src/utils/stringUtils.ts @@ -0,0 +1,8 @@ +/** + * Normalizes text by converting it to Unicode NFD form and removing diacritic marks. + * E.g., 'Café' -> 'Cafe', 'Résumé' -> 'Resume', 'München' -> 'Munchen', 'Ñoño' -> 'Nono' + */ +export function normalizeText(text: string): string { + if (!text) return ''; + return text.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); +} diff --git a/tests/services/searchIndex.test.ts b/tests/services/searchIndex.test.ts new file mode 100644 index 00000000..e48069d2 --- /dev/null +++ b/tests/services/searchIndex.test.ts @@ -0,0 +1,107 @@ +import { SearchIndexService } from '../../src/services/searchIndex'; +import { Course } from '../../src/types/course'; + +describe('SearchIndexService diacritics normalization', () => { + let service: SearchIndexService; + + beforeEach(() => { + service = new SearchIndexService(); + }); + + it('finds course titled "Café" when searching "cafe"', async () => { + const courses: Course[] = [ + { + id: 'course-1', + title: 'Café Communication', + description: 'Learn communication skills.', + instructor: { id: 'inst-1', name: 'John Doe' }, + sections: [], + totalLessons: 5, + totalDuration: 60, + level: 'beginner', + category: 'Language', + }, + ]; + + await service.buildFromCourses(courses); + + // Test 'Cafe' / 'cafe' search finding 'Café' + const results = service.search('Cafe'); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('course-1'); + expect(results[0].title).toBe('Café Communication'); // Original display text preserved + }); + + it('finds course titled "Résumé" when searching "Resume"', async () => { + const courses: Course[] = [ + { + id: 'course-2', + title: 'Résumé Writing', + description: 'Professional CV preparation.', + instructor: { id: 'inst-2', name: 'Jane Doe' }, + sections: [], + totalLessons: 5, + totalDuration: 60, + level: 'beginner', + category: 'Careers', + }, + ]; + + await service.buildFromCourses(courses); + + const results = service.search('Resume'); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('course-2'); + expect(results[0].title).toBe('Résumé Writing'); // Original display text preserved + }); + + it('finds course titled "München" when searching "munchen"', async () => { + const courses: Course[] = [ + { + id: 'course-3', + title: 'München History', + description: 'History of Munich (München).', + instructor: { id: 'inst-3', name: 'Karl' }, + sections: [], + totalLessons: 5, + totalDuration: 60, + level: 'beginner', + category: 'History', + }, + ]; + + await service.buildFromCourses(courses); + + // Test 'munchen' finding 'München' (diacritics normalization) + const resultsMunchen = service.search('munchen'); + expect(resultsMunchen).toHaveLength(1); + expect(resultsMunchen[0].id).toBe('course-3'); + + // Test 'Munich' finding 'München' (since 'Munich' is in description/indexed fields) + const resultsMunich = service.search('Munich'); + expect(resultsMunich).toHaveLength(1); + expect(resultsMunich[0].id).toBe('course-3'); + }); + + it('finds course titled "Ñoño" when searching "nono"', async () => { + const courses: Course[] = [ + { + id: 'course-4', + title: 'El curso de Ñoño', + description: 'Un curso divertido.', + instructor: { id: 'inst-4', name: 'Nico' }, + sections: [], + totalLessons: 5, + totalDuration: 60, + level: 'beginner', + category: 'Culture', + }, + ]; + + await service.buildFromCourses(courses); + + const results = service.search('nono'); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('course-4'); + }); +}); From 83760937ab3f0bcd79135535a18044cb0c60e4fb Mon Sep 17 00:00:00 2001 From: itzabdoull Date: Tue, 30 Jun 2026 09:23:24 +0100 Subject: [PATCH 2/2] chore: remove baseUrl and update paths to relative in tsconfig.json --- tsconfig.json | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 39e8bdb8..c26688cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,14 +6,12 @@ "jsx": "react-native", "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "baseUrl": ".", - "ignoreDeprecations": "6.0", "paths": { - "@components/*": ["src/components/*"], - "@hooks/*": ["src/hooks/*"], - "@services/*": ["src/services/*"], - "@store/*": ["src/store/*"], - "@utils/*": ["src/utils/*"], + "@components/*": ["./src/components/*"], + "@hooks/*": ["./src/hooks/*"], + "@services/*": ["./src/services/*"], + "@store/*": ["./src/store/*"], + "@utils/*": ["./src/utils/*"], "@/components/*": ["./src/components/*", "./components/*"], "@/hooks/*": ["./src/hooks/*", "./hooks/*"], "@/constants/*": ["./constants/*"],