diff --git a/app/_layout.tsx b/app/_layout.tsx index 11e1b1f..0a3e69d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,15 +1,21 @@ -import { Stack, useRouter, usePathname, useSegments } from "expo-router"; -import React, { useCallback, useEffect } from "react"; -import { GestureHandlerRootView } from "react-native-gesture-handler"; -import "react-native-reanimated"; -import "../global.css"; // NativeWind CSS -import { AnalyticsProvider, ErrorBoundary, OfflineIndicatorProvider } from "../src/components"; +import { Stack, useRouter, usePathname, useSegments } from 'expo-router'; +import React, { useCallback, useEffect } from 'react'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + +import 'react-native-reanimated'; +import '../global.css'; // NativeWind CSS +import { + AnalyticsProvider, + ErrorBoundary, + OfflineIndicatorProvider, + SearchIndexProvider, +} from '../src/components'; import { useAnalytics } from '../src/hooks'; import { useDeepLink } from '../src/hooks/useDeepLink'; import { getPathFromDeepLink } from '../src/utils/linkParser'; // Component to handle auto screen tracking -function ScreenTracker() { +const ScreenTracker = () => { const pathname = usePathname(); const segments = useSegments(); const { trackScreen } = useAnalytics(); @@ -22,17 +28,20 @@ function ScreenTracker() { }, [pathname, segments, trackScreen]); return null; -} +}; -export default function RootLayout() { +const RootLayout = () => { const router = useRouter(); - const handleDeepLink = useCallback((deepLink) => { - const path = getPathFromDeepLink(deepLink); - if (path) { - router.replace(path); - } - }, [router]); + const handleDeepLink = useCallback( + deepLink => { + const path = getPathFromDeepLink(deepLink); + if (path) { + router.replace(path); + } + }, + [router] + ); useDeepLink(handleDeepLink); @@ -41,20 +50,24 @@ export default function RootLayout() { - - - - - - - - - - - - + + + + + + + + + + + + + + ); -} +}; + +export default RootLayout; diff --git a/src/__tests__/services/searchIndex/SearchIndex.test.ts b/src/__tests__/services/searchIndex/SearchIndex.test.ts new file mode 100644 index 0000000..9f1eead --- /dev/null +++ b/src/__tests__/services/searchIndex/SearchIndex.test.ts @@ -0,0 +1,195 @@ +import { SearchIndex } from '../../../services/searchIndex/SearchIndex'; +import { IndexableDoc } from '../../../services/searchIndex/types'; + +function doc( + id: string, + title: string, + body = '', + category = '', + level: 'beginner' | 'intermediate' | 'advanced' = 'beginner' +): IndexableDoc { + return { id, type: 'course', fields: { title, body, category, level } }; +} + +describe('SearchIndex', () => { + let index: SearchIndex; + + beforeEach(() => { + index = new SearchIndex(); + }); + + describe('build / size / clear', () => { + it('reports size after build', () => { + index.build([doc('1', 'A'), doc('2', 'B')]); + expect(index.size()).toBe(2); + expect(index.isReady()).toBe(true); + }); + + it('clear empties the index', () => { + index.build([doc('1', 'A')]); + index.clear(); + expect(index.size()).toBe(0); + expect(index.isReady()).toBe(false); + }); + }); + + describe('search', () => { + beforeEach(() => { + index.build([ + doc('1', 'React Native Fundamentals', 'Learn mobile development', 'Mobile Development'), + doc('2', 'Advanced JavaScript', 'Closures and prototypes', 'Web Development', 'advanced'), + doc('3', 'Mobile UI Design', 'Design beautiful interfaces', 'Design'), + doc('4', 'React Hooks Deep Dive', 'useState useEffect useMemo', 'Web Development'), + doc('5', 'Native Modules in Expo', 'Bridging native code', 'Mobile Development'), + ]); + }); + + it('returns empty when no token matches', () => { + expect(index.search('nonsensequery')).toEqual([]); + }); + + it('finds title matches', () => { + const hits = index.search('react'); + const ids = hits.map(h => h.id); + expect(ids).toEqual(expect.arrayContaining(['1', '4'])); + }); + + it('intersects multi-token queries (AND)', () => { + const hits = index.search('react native'); + expect(hits.map(h => h.id)).toEqual(['1']); + }); + + it('matches across body and category', () => { + const hits = index.search('design'); + expect(hits.map(h => h.id)).toEqual(expect.arrayContaining(['3'])); + }); + + it('supports prefix match on the trailing token', () => { + const hits = index.search('rea'); + const ids = hits.map(h => h.id); + expect(ids).toEqual(expect.arrayContaining(['1', '4'])); + }); + + it('ranks title hits above body-only hits', () => { + const fresh = new SearchIndex(); + fresh.build([ + doc('a', 'Body Only', 'this lesson covers expo deeply', 'Web Development'), + doc('b', 'Expo Deep Dive', 'introductory material', 'Mobile Development'), + ]); + const hits = fresh.search('expo'); + expect(hits[0].id).toBe('b'); + }); + + it('applies category filters', () => { + const hits = index.search('mobile', { filters: { category: 'Mobile Development' } }); + expect(hits.every(h => h.doc.fields.category === 'Mobile Development')).toBe(true); + }); + + it('applies level filters', () => { + const hits = index.search('javascript', { filters: { level: 'advanced' } }); + expect(hits.map(h => h.id)).toEqual(['2']); + }); + + it('respects the limit option', () => { + const hits = index.search('react', { limit: 1 }); + expect(hits).toHaveLength(1); + }); + + it('returns filtered docs when query is empty', () => { + const hits = index.search('', { filters: { category: 'Design' }, limit: 10 }); + expect(hits.map(h => h.id)).toEqual(['3']); + }); + }); + + describe('update / remove', () => { + it('update inserts a new doc', () => { + index.build([doc('1', 'A')]); + index.update(doc('2', 'Beta')); + expect(index.size()).toBe(2); + expect(index.search('beta').map(h => h.id)).toEqual(['2']); + }); + + it('update replaces an existing doc', () => { + index.build([doc('1', 'Old Title')]); + index.update(doc('1', 'New Title')); + expect(index.search('old')).toEqual([]); + expect(index.search('new').map(h => h.id)).toEqual(['1']); + }); + + it('remove drops a doc and its postings', () => { + index.build([doc('1', 'Alpha'), doc('2', 'Beta')]); + index.remove('1'); + expect(index.size()).toBe(1); + expect(index.search('alpha')).toEqual([]); + }); + }); + + describe('serialize / hydrate', () => { + it('round-trips through a snapshot', () => { + index.build([doc('1', 'Alpha'), doc('2', 'Beta')]); + const snap = index.serialize(); + const fresh = new SearchIndex(); + fresh.hydrate(snap); + expect(fresh.size()).toBe(2); + expect(fresh.search('alpha').map(h => h.id)).toEqual(['1']); + }); + + it('rejects snapshots with mismatched version', () => { + index.build([doc('1', 'Alpha')]); + const snap = index.serialize(); + const fresh = new SearchIndex(); + fresh.hydrate({ ...snap, version: 999 }); + expect(fresh.size()).toBe(0); + }); + }); + + describe('performance', () => { + it('searches 500 docs in under 100ms', () => { + const docs: IndexableDoc[] = []; + const categories = ['Mobile Development', 'Web Development', 'Design', 'Data']; + const wordPool = [ + 'react', + 'native', + 'expo', + 'javascript', + 'typescript', + 'design', + 'mobile', + 'web', + 'animation', + 'navigation', + 'hooks', + 'modules', + 'performance', + 'testing', + 'analytics', + 'data', + 'rendering', + ]; + for (let i = 0; i < 500; i++) { + const w1 = wordPool[i % wordPool.length]; + const w2 = wordPool[(i * 3) % wordPool.length]; + const w3 = wordPool[(i * 7) % wordPool.length]; + docs.push({ + id: `c${i}`, + type: 'course', + fields: { + title: `${w1} ${w2} course ${i}`, + body: `An in-depth look at ${w1} and ${w3}, covering practical examples.`, + category: categories[i % categories.length], + level: 'beginner', + }, + }); + } + index.build(docs); + + const start = Date.now(); + for (let i = 0; i < 10; i++) { + index.search('react native', { limit: 25 }); + } + const elapsed = Date.now() - start; + const perCall = elapsed / 10; + expect(perCall).toBeLessThan(100); + }); + }); +}); diff --git a/src/__tests__/services/searchIndex/persistence.test.ts b/src/__tests__/services/searchIndex/persistence.test.ts new file mode 100644 index 0000000..bd66716 --- /dev/null +++ b/src/__tests__/services/searchIndex/persistence.test.ts @@ -0,0 +1,98 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { + SNAPSHOT_KEY, + clearSnapshot, + loadSnapshot, + saveSnapshot, +} from '../../../services/searchIndex/persistence'; +import { INDEX_VERSION, IndexSnapshot } from '../../../services/searchIndex/types'; + +const setItem = AsyncStorage.setItem as jest.Mock; +const getItem = AsyncStorage.getItem as jest.Mock; +const removeItem = AsyncStorage.removeItem as jest.Mock; + +function withInMemoryStore() { + const store = new Map(); + setItem.mockImplementation((k: string, v: string) => { + store.set(k, v); + return Promise.resolve(); + }); + getItem.mockImplementation((k: string) => Promise.resolve(store.get(k) ?? null)); + removeItem.mockImplementation((k: string) => { + store.delete(k); + return Promise.resolve(); + }); + return store; +} + +function snapshot(docs: IndexSnapshot['docs'] = []): IndexSnapshot { + return { + version: INDEX_VERSION, + hash: 'h1', + builtAt: Date.now(), + docs, + }; +} + +describe('searchIndex/persistence', () => { + beforeEach(() => { + setItem.mockReset(); + getItem.mockReset(); + removeItem.mockReset(); + }); + + it('round-trips a snapshot through AsyncStorage', async () => { + withInMemoryStore(); + const snap = snapshot([ + { id: '1', type: 'course', fields: { title: 'Alpha' } }, + { id: '2', type: 'course', fields: { title: 'Beta' } }, + ]); + const ok = await saveSnapshot(snap); + expect(ok).toBe(true); + + const loaded = await loadSnapshot(); + expect(loaded).not.toBeNull(); + expect(loaded?.docs).toHaveLength(2); + expect(loaded?.docs[0].fields.title).toBe('Alpha'); + }); + + it('returns null when no snapshot is stored', async () => { + getItem.mockResolvedValueOnce(null); + const loaded = await loadSnapshot(); + expect(loaded).toBeNull(); + }); + + it('returns null when stored payload is corrupt', async () => { + getItem.mockResolvedValueOnce('{not json'); + const loaded = await loadSnapshot(); + expect(loaded).toBeNull(); + }); + + it('returns null when version is mismatched', async () => { + getItem.mockResolvedValueOnce( + JSON.stringify({ version: 999, hash: 'x', builtAt: 0, docs: [] }) + ); + const loaded = await loadSnapshot(); + expect(loaded).toBeNull(); + }); + + it('skips persistence when payload exceeds size cap', async () => { + withInMemoryStore(); + const huge = 'x'.repeat(50_000); + const docs: IndexSnapshot['docs'] = []; + for (let i = 0; i < 10; i++) { + docs.push({ id: `d${i}`, type: 'course', fields: { title: huge, body: huge } }); + } + const ok = await saveSnapshot(snapshot(docs)); + expect(ok).toBe(false); + expect(setItem).not.toHaveBeenCalled(); + }); + + it('clearSnapshot removes the stored entry', async () => { + const store = withInMemoryStore(); + store.set(SNAPSHOT_KEY, '{"version":1}'); + await clearSnapshot(); + expect(removeItem).toHaveBeenCalledWith(SNAPSHOT_KEY); + }); +}); diff --git a/src/__tests__/services/searchIndex/tokenize.test.ts b/src/__tests__/services/searchIndex/tokenize.test.ts new file mode 100644 index 0000000..81721cc --- /dev/null +++ b/src/__tests__/services/searchIndex/tokenize.test.ts @@ -0,0 +1,60 @@ +import { + tokenize, + normalize, + uniqueTokens, + STOPWORDS, +} from '../../../services/searchIndex/tokenize'; + +describe('searchIndex/tokenize', () => { + describe('normalize', () => { + it('lowercases input', () => { + expect(normalize('React Native')).toBe('react native'); + }); + + it('strips diacritics', () => { + expect(normalize('Café')).toBe('cafe'); + expect(normalize('Año')).toBe('ano'); + }); + }); + + describe('tokenize', () => { + it('returns an empty array for empty input', () => { + expect(tokenize('')).toEqual([]); + }); + + it('splits on whitespace and punctuation', () => { + expect(tokenize('React-Native: A primer.')).toEqual(['react', 'native', 'primer']); + }); + + it('drops single-character tokens', () => { + expect(tokenize('a b cc dd')).toEqual(['cc', 'dd']); + }); + + it('drops stopwords', () => { + const tokens = tokenize('Learn the basics of mobile development'); + expect(tokens).not.toContain('the'); + expect(tokens).not.toContain('of'); + expect(tokens).toEqual(expect.arrayContaining(['learn', 'basics', 'mobile', 'development'])); + }); + + it('keeps numerics', () => { + expect(tokenize('iOS 17 release notes')).toEqual( + expect.arrayContaining(['ios', '17', 'release', 'notes']) + ); + }); + }); + + describe('uniqueTokens', () => { + it('deduplicates tokens', () => { + expect(uniqueTokens('react react native react')).toEqual(['react', 'native']); + }); + }); + + describe('STOPWORDS', () => { + it('contains common English stopwords', () => { + expect(STOPWORDS.has('the')).toBe(true); + expect(STOPWORDS.has('and')).toBe(true); + expect(STOPWORDS.has('with')).toBe(true); + }); + }); +}); diff --git a/src/components/SearchIndexProvider.tsx b/src/components/SearchIndexProvider.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/index.ts b/src/components/index.ts index 2bd2dec..ed81a01 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -12,3 +12,6 @@ export { CourseCardSkeleton } from './mobile/CourseCardSkeleton'; export { default as MobileCourseViewer } from './mobile/MobileCourseViewer'; export { MobileHeader } from './mobile/MobileHeader'; export { default as MobileQuizManager } from './mobile/MobileQuizManager'; + +export { SearchIndexProvider } from './SearchIndexProvider'; +export type { SearchIndexContextValue } from './SearchIndexProvider'; diff --git a/src/components/mobile/MobileSearch.tsx b/src/components/mobile/MobileSearch.tsx index e2acbca..1c5cc22 100644 --- a/src/components/mobile/MobileSearch.tsx +++ b/src/components/mobile/MobileSearch.tsx @@ -9,17 +9,23 @@ import { TouchableOpacity, View, } from 'react-native'; -import { AppText as Text } from '../common/AppText'; -import { useAnalytics, useDebounce, useDynamicFontSize, useMemoryMonitor } from '../../hooks'; -import { AnalyticsEvent } from '../../utils/trackingEvents'; + import { FilterField, FilterSheet, FilterValues } from './FilterSheet'; import { SearchHistory } from './SearchHistory'; import { SearchResultCard, SearchResultItem } from './SearchResultCard'; +import { VoiceSearch } from './VoiceSearch'; +import { + useAnalytics, + useDebounce, + useDynamicFontSize, + useMemoryMonitor, + useSearchIndex, +} from '../../hooks'; +import { SearchHit } from '../../services/searchIndex'; import { addToSearchHistory } from '../../utils/searchHistory'; +import { AnalyticsEvent } from '../../utils/trackingEvents'; import { validateSearchQuery } from '../../utils/validation'; -import { sampleCourse } from '../../data/sampleCourse'; -import { Course } from '../../types/course'; -import { VoiceSearch } from './VoiceSearch'; +import { AppText as Text } from '../common/AppText'; const DEFAULT_FILTERS: FilterField[] = [ { @@ -52,31 +58,20 @@ const SUGGESTION_KEYWORDS = [ 'beginner', ]; -function courseToSearchResult(course: Course): SearchResultItem { +function hitToSearchResult(hit: SearchHit): SearchResultItem { + const payload = (hit.doc.payload ?? {}) as { + totalDuration?: number; + }; return { - id: course.id, - title: course.title, - description: course.description, - category: course.category, - level: course.level, - duration: course.totalDuration, + id: hit.id, + title: hit.doc.fields.title, + description: hit.doc.fields.body ?? '', + category: hit.doc.fields.category ?? '', + level: (hit.doc.fields.level as SearchResultItem['level']) ?? 'beginner', + duration: payload.totalDuration ?? 0, }; } -function filterCourse(course: Course, query: string, filters: FilterValues): boolean { - const q = query.trim().toLowerCase(); - if (q) { - const match = - course.title.toLowerCase().includes(q) || - course.description.toLowerCase().includes(q) || - course.category.toLowerCase().includes(q); - if (!match) return false; - } - if (filters.category && course.category !== filters.category) return false; - if (filters.level && course.level !== filters.level) return false; - return true; -} - /** * Props for the MobileSearch component */ @@ -100,6 +95,7 @@ export const MobileSearch = ({ const [hasSearched, setHasSearched] = useState(false); const { scale } = useDynamicFontSize(); const { trackEvent } = useAnalytics(); + const { search: indexSearch } = useSearchIndex(); useMemoryMonitor({ componentId: 'MobileSearch', itemCount: results.length }); @@ -126,14 +122,19 @@ export const MobileSearch = ({ const trimmed = searchQuery.trim(); addToSearchHistory(trimmed); trackEvent(AnalyticsEvent.SEARCH_QUERY, { query: trimmed, filters: filterValues }); - const filtered = filterCourse(sampleCourse, trimmed, filterValues) - ? [courseToSearchResult(sampleCourse)] - : []; - setResults(filtered); + const hits = indexSearch(trimmed, { + filters: { + category: filterValues.category, + level: filterValues.level, + type: 'course', + }, + limit: 50, + }); + setResults(hits.map(hitToSearchResult)); setHasSearched(true); setSuggestionsVisible(false); }, - [filterValues, trackEvent] + [filterValues, trackEvent, indexSearch] ); React.useEffect(() => { @@ -146,7 +147,6 @@ export const MobileSearch = ({ } }, [debouncedQuery, performSearch]); - const handleSubmit = useCallback(() => { performSearch(query); }, [query, performSearch]); @@ -198,7 +198,10 @@ export const MobileSearch = ({ placeholder={placeholder} placeholderTextColor="#9CA3AF" value={query} - onChangeText={(text) => { setQuery(text); setQueryError(null); }} + onChangeText={text => { + setQuery(text); + setQueryError(null); + }} onFocus={() => setSuggestionsVisible(true)} onBlur={() => setTimeout(() => setSuggestionsVisible(false), 180)} onSubmitEditing={handleSubmit} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ef8dc5f..1e79b26 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -22,3 +22,4 @@ export * from './useSwipe'; export * from './useVideoGestures'; export * from './useVoiceRecognition'; export * from './useDebounce'; +export * from './useSearchIndex'; diff --git a/src/hooks/useSearchIndex.ts b/src/hooks/useSearchIndex.ts new file mode 100644 index 0000000..e08c261 --- /dev/null +++ b/src/hooks/useSearchIndex.ts @@ -0,0 +1,16 @@ +import { useContext } from 'react'; + +import { SearchIndexContext, SearchIndexContextValue } from '../components/SearchIndexProvider'; + +export function useSearchIndex(): SearchIndexContextValue { + const ctx = useContext(SearchIndexContext); + if (!ctx) { + return { + ready: false, + size: 0, + search: () => [], + rebuild: async () => {}, + }; + } + return ctx; +} diff --git a/src/services/searchIndex/SearchIndex.ts b/src/services/searchIndex/SearchIndex.ts new file mode 100644 index 0000000..d954fe3 --- /dev/null +++ b/src/services/searchIndex/SearchIndex.ts @@ -0,0 +1,241 @@ +import { tokenize, uniqueTokens } from './tokenize'; +import { + FIELD_WEIGHTS, + INDEX_VERSION, + IndexSnapshot, + IndexableDoc, + SearchHit, + SearchOptions, +} from './types'; + +const DEFAULT_LIMIT = 25; +const PREFIX_BONUS = 0.5; + +function fieldText(doc: IndexableDoc): [keyof typeof FIELD_WEIGHTS, string][] { + const f = doc.fields; + const out: [keyof typeof FIELD_WEIGHTS, string][] = []; + if (f.title) out.push(['title', f.title]); + if (f.category) out.push(['category', f.category]); + if (f.level) out.push(['level', f.level]); + if (f.body) out.push(['body', f.body]); + if (f.extra) out.push(['extra', f.extra]); + return out; +} + +function hashContent(docs: IndexableDoc[]): string { + let h = 5381; + for (const d of docs) { + const s = `${d.id}|${d.fields.title}|${d.fields.category ?? ''}|${d.fields.level ?? ''}|${d.fields.body ?? ''}|${d.fields.extra ?? ''}`; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) + h + s.charCodeAt(i)) | 0; + } + } + return (h >>> 0).toString(36); +} + +/** + * In-memory inverted index for offline search. + * + * Postings: token → set of doc ids. + * Token weights: token → docId → score contribution (sum of field weights). + * Search intersects per-token posting lists (AND semantics) and ranks by summed + * field weights with a bonus for prefix-only matches on the final query token. + */ +export class SearchIndex { + private docs = new Map(); + private postings = new Map>(); + private weights = new Map>(); + private contentHash = ''; + private builtAt = 0; + + build(docs: IndexableDoc[]): void { + this.clear(); + for (const doc of docs) this.indexDoc(doc); + this.contentHash = hashContent(Array.from(this.docs.values())); + this.builtAt = Date.now(); + } + + update(doc: IndexableDoc): void { + if (this.docs.has(doc.id)) this.removePostings(doc.id); + this.indexDoc(doc); + this.contentHash = hashContent(Array.from(this.docs.values())); + } + + remove(id: string): void { + if (!this.docs.has(id)) return; + this.removePostings(id); + this.docs.delete(id); + this.contentHash = hashContent(Array.from(this.docs.values())); + } + + clear(): void { + this.docs.clear(); + this.postings.clear(); + this.weights.clear(); + this.contentHash = ''; + this.builtAt = 0; + } + + size(): number { + return this.docs.size; + } + + isReady(): boolean { + return this.docs.size > 0; + } + + search(query: string, opts: SearchOptions = {}): SearchHit[] { + const limit = opts.limit ?? DEFAULT_LIMIT; + const tokens = uniqueTokens(query); + if (tokens.length === 0) { + return this.applyFilters(Array.from(this.docs.values()), opts) + .slice(0, limit) + .map(doc => ({ id: doc.id, type: doc.type, score: 0, doc })); + } + + const candidates = this.collectCandidates(tokens); + if (candidates.size === 0) return []; + + const scored: SearchHit[] = []; + for (const id of candidates) { + const doc = this.docs.get(id); + if (!doc) continue; + if (!this.matchesFilters(doc, opts)) continue; + let score = 0; + for (const t of tokens) { + const w = this.weights.get(t)?.get(id) ?? 0; + score += w; + if (w === 0) { + score += this.prefixScore(t, id); + } + } + if (score > 0) scored.push({ id, type: doc.type, score, doc }); + } + + scored.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.doc.fields.title.localeCompare(b.doc.fields.title); + }); + return scored.slice(0, limit); + } + + serialize(): IndexSnapshot { + return { + version: INDEX_VERSION, + hash: this.contentHash, + builtAt: this.builtAt || Date.now(), + docs: Array.from(this.docs.values()), + }; + } + + hydrate(snapshot: IndexSnapshot): void { + if (snapshot.version !== INDEX_VERSION) { + this.clear(); + return; + } + this.build(snapshot.docs); + this.builtAt = snapshot.builtAt; + } + + getContentHash(): string { + return this.contentHash; + } + + private indexDoc(doc: IndexableDoc): void { + this.docs.set(doc.id, doc); + for (const [field, text] of fieldText(doc)) { + const weight = FIELD_WEIGHTS[field]; + const seen = new Set(); + for (const token of tokenize(text)) { + let posting = this.postings.get(token); + if (!posting) { + posting = new Set(); + this.postings.set(token, posting); + } + posting.add(doc.id); + + let perToken = this.weights.get(token); + if (!perToken) { + perToken = new Map(); + this.weights.set(token, perToken); + } + const prior = perToken.get(doc.id) ?? 0; + const bump = seen.has(token) ? Math.ceil(weight / 2) : weight; + perToken.set(doc.id, prior + bump); + seen.add(token); + } + } + } + + private removePostings(id: string): void { + for (const [token, set] of this.postings) { + if (set.delete(id) && set.size === 0) this.postings.delete(token); + } + for (const [token, map] of this.weights) { + if (map.delete(id) && map.size === 0) this.weights.delete(token); + } + } + + private collectCandidates(tokens: string[]): Set { + const lists: Set[] = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const exact = this.postings.get(token); + const isLast = i === tokens.length - 1; + if (exact && exact.size) { + lists.push(exact); + continue; + } + if (isLast) { + const prefixHits = this.prefixPosting(token); + if (prefixHits.size === 0) return new Set(); + lists.push(prefixHits); + continue; + } + return new Set(); + } + lists.sort((a, b) => a.size - b.size); + const [first, ...rest] = lists; + const result = new Set(); + for (const id of first) { + if (rest.every(s => s.has(id))) result.add(id); + } + return result; + } + + private prefixPosting(prefix: string): Set { + const out = new Set(); + for (const [token, set] of this.postings) { + if (token.startsWith(prefix)) { + for (const id of set) out.add(id); + } + } + return out; + } + + private prefixScore(prefix: string, docId: string): number { + let best = 0; + for (const [token, perToken] of this.weights) { + if (!token.startsWith(prefix)) continue; + const w = perToken.get(docId); + if (w && w > best) best = w; + } + return best > 0 ? best * PREFIX_BONUS : 0; + } + + private matchesFilters(doc: IndexableDoc, opts: SearchOptions): boolean { + const f = opts.filters; + if (!f) return true; + if (f.type && doc.type !== f.type) return false; + if (f.category && doc.fields.category !== f.category) return false; + if (f.level && doc.fields.level !== f.level) return false; + return true; + } + + private applyFilters(docs: IndexableDoc[], opts: SearchOptions): IndexableDoc[] { + if (!opts.filters) return docs; + return docs.filter(d => this.matchesFilters(d, opts)); + } +} + +export const searchIndex = new SearchIndex(); diff --git a/src/services/searchIndex/courseAdapter.ts b/src/services/searchIndex/courseAdapter.ts new file mode 100644 index 0000000..6ad5b8b --- /dev/null +++ b/src/services/searchIndex/courseAdapter.ts @@ -0,0 +1,22 @@ +import { IndexableDoc } from './types'; +import { Course } from '../../types/course'; + +export function courseToIndexable(course: Course): IndexableDoc { + return { + id: course.id, + type: 'course', + fields: { + title: course.title, + body: course.description, + category: course.category, + level: course.level, + extra: course.instructor?.name, + }, + payload: { + thumbnail: course.thumbnail, + totalDuration: course.totalDuration, + totalLessons: course.totalLessons, + instructorName: course.instructor?.name, + }, + }; +} diff --git a/src/services/searchIndex/index.ts b/src/services/searchIndex/index.ts new file mode 100644 index 0000000..7d2d888 --- /dev/null +++ b/src/services/searchIndex/index.ts @@ -0,0 +1,5 @@ +export { SearchIndex, searchIndex } from './SearchIndex'; +export { courseToIndexable } from './courseAdapter'; +export { loadSnapshot, saveSnapshot, clearSnapshot, SNAPSHOT_KEY } from './persistence'; +export { tokenize, normalize, uniqueTokens, STOPWORDS } from './tokenize'; +export type { IndexableDoc, SearchHit, SearchOptions, IndexSnapshot } from './types'; diff --git a/src/services/searchIndex/persistence.ts b/src/services/searchIndex/persistence.ts new file mode 100644 index 0000000..53c6305 --- /dev/null +++ b/src/services/searchIndex/persistence.ts @@ -0,0 +1,57 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { INDEX_VERSION, IndexSnapshot } from './types'; +import { appLogger } from '../../utils/logger'; + +export const SNAPSHOT_KEY = '@teachlink_search_index_v1'; +const MAX_PAYLOAD_BYTES = 200 * 1024; + +export async function loadSnapshot(): Promise { + try { + const raw = await AsyncStorage.getItem(SNAPSHOT_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as IndexSnapshot; + if (!parsed || parsed.version !== INDEX_VERSION || !Array.isArray(parsed.docs)) { + return null; + } + return parsed; + } catch (err) { + appLogger.error('searchIndex: failed to load snapshot', err); + return null; + } +} + +/** + * Save a snapshot. If the serialized payload exceeds MAX_PAYLOAD_BYTES, persist + * a doc-only snapshot (postings rebuilt on hydrate). Returns true on success. + */ +export async function saveSnapshot(snapshot: IndexSnapshot): Promise { + try { + const stripped: IndexSnapshot = { + version: snapshot.version, + hash: snapshot.hash, + builtAt: snapshot.builtAt, + docs: snapshot.docs, + }; + const payload = JSON.stringify(stripped); + if (payload.length > MAX_PAYLOAD_BYTES) { + appLogger.warn( + `searchIndex: snapshot ${payload.length}B exceeds cap ${MAX_PAYLOAD_BYTES}B; skipping persistence` + ); + return false; + } + await AsyncStorage.setItem(SNAPSHOT_KEY, payload); + return true; + } catch (err) { + appLogger.error('searchIndex: failed to save snapshot', err); + return false; + } +} + +export async function clearSnapshot(): Promise { + try { + await AsyncStorage.removeItem(SNAPSHOT_KEY); + } catch (err) { + appLogger.error('searchIndex: failed to clear snapshot', err); + } +} diff --git a/src/services/searchIndex/tokenize.ts b/src/services/searchIndex/tokenize.ts new file mode 100644 index 0000000..abe2412 --- /dev/null +++ b/src/services/searchIndex/tokenize.ts @@ -0,0 +1,48 @@ +export const STOPWORDS = new Set([ + 'a', + 'an', + 'and', + 'are', + 'as', + 'at', + 'be', + 'by', + 'for', + 'from', + 'has', + 'in', + 'is', + 'it', + 'of', + 'on', + 'or', + 'that', + 'the', + 'to', + 'was', + 'were', + 'will', + 'with', +]); + +const NON_ALNUM = /[^\p{L}\p{N}]+/gu; + +export function normalize(text: string): string { + return text.toLowerCase().normalize('NFKD').replace(/\p{M}/gu, ''); +} + +export function tokenize(text: string): string[] { + if (!text) return []; + const normalized = normalize(text).replace(NON_ALNUM, ' '); + const tokens: string[] = []; + for (const raw of normalized.split(' ')) { + if (raw.length < 2) continue; + if (STOPWORDS.has(raw)) continue; + tokens.push(raw); + } + return tokens; +} + +export function uniqueTokens(text: string): string[] { + return Array.from(new Set(tokenize(text))); +} diff --git a/src/services/searchIndex/types.ts b/src/services/searchIndex/types.ts new file mode 100644 index 0000000..dd9f89d --- /dev/null +++ b/src/services/searchIndex/types.ts @@ -0,0 +1,46 @@ +/** + * Document accepted by the search index. Adapters (e.g. courseAdapter) + * convert domain types into this shape. + */ +export interface IndexableDoc { + id: string; + type: 'course' | 'user'; + fields: { + title: string; + body?: string; + category?: string; + level?: string; + extra?: string; + }; + payload?: Record; +} + +export interface SearchHit { + id: string; + type: 'course' | 'user'; + score: number; + doc: IndexableDoc; +} + +export interface SearchOptions { + limit?: number; + filters?: { category?: string; level?: string; type?: 'course' | 'user' }; +} + +export interface IndexSnapshot { + version: number; + hash: string; + builtAt: number; + docs: IndexableDoc[]; + postings?: Record; +} + +export const FIELD_WEIGHTS = { + title: 5, + category: 3, + level: 2, + body: 1, + extra: 1, +} as const; + +export const INDEX_VERSION = 1;