Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
13 changes: 7 additions & 6 deletions src/services/searchIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<string, number>();
Expand Down
8 changes: 8 additions & 0 deletions src/utils/stringUtils.ts
Original file line number Diff line number Diff line change
@@ -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, '');
}
107 changes: 107 additions & 0 deletions tests/services/searchIndex.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
12 changes: 5 additions & 7 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"],
Expand Down
Loading