|
10 | 10 | import { frequentlyUsedTools, toolUsage } from '$lib/stores/toolUsage'; |
11 | 11 | import { tooltip } from '$lib/actions/tooltip'; |
12 | 12 | import { formatShortcut } from '$lib/utils/keyboard'; |
| 13 | + import { searchPages } from '$lib/utils/search'; |
13 | 14 |
|
14 | 15 | interface Props { |
15 | 16 | embedded?: boolean; // Whether this is embedded in a page vs popup modal |
|
37 | 38 | let selectedIndex = $state(0); |
38 | 39 | let searchInput: HTMLInputElement | undefined = $state(); |
39 | 40 |
|
40 | | - // Levenshtein distance for fuzzy matching |
41 | | -
|
42 | | - /** |
43 | | - * Calculate the Levenshtein distance between two strings (aka similarity score) |
44 | | - * Ok, chill before you scream "import a library"... |
45 | | - * It's not as bad as it looks.... Basically, we're just creating a 2D array |
46 | | - * and filling it in based on character comparisons. The array size is small |
47 | | - * (query length x label length) so performance is not a big deal here. |
48 | | - * It's just a rudimentary fuzzy search to catch typos and close matches. |
49 | | - * @param a (string 1) |
50 | | - * @param b (string 2) |
51 | | - */ |
52 | | - function levenshtein(a: string, b: string): number { |
53 | | - const matrix: number[][] = []; |
54 | | - for (let i = 0; i <= b.length; i++) matrix[i] = [i]; |
55 | | - for (let j = 0; j <= a.length; j++) matrix[0]![j] = j; |
56 | | - for (let i = 1; i <= b.length; i++) { |
57 | | - for (let j = 1; j <= a.length; j++) { |
58 | | - if (b.charAt(i - 1) === a.charAt(j - 1)) { |
59 | | - matrix[i]![j] = matrix[i - 1]![j - 1]!; |
60 | | - } else { |
61 | | - matrix[i]![j] = Math.min(matrix[i - 1]![j - 1]! + 1, matrix[i]![j - 1]! + 1, matrix[i - 1]![j]! + 1); |
62 | | - } |
63 | | - } |
64 | | - } |
65 | | - return matrix[b.length]![a.length]!; |
66 | | - } |
67 | | -
|
68 | 41 | // Get smart suggestions when no results |
69 | 42 | function getSuggested(): NavItem[] { |
70 | 43 | const fromStores = [...$frequentlyUsedTools, ...$bookmarks] |
|
80 | 53 | function search(q: string): NavItem[] { |
81 | 54 | if (!q.trim()) return []; |
82 | 55 |
|
83 | | - const normalizedQuery = q.toLowerCase().trim(); |
84 | | - const tokens = normalizedQuery.split(/\s+/); |
85 | 56 | const bookmarkedHrefs = new Set($bookmarks.map((b) => b.href)); |
86 | 57 | const frequentHrefs = new Set($frequentlyUsedTools.map((t) => t.href)); |
87 | 58 |
|
88 | | - const results = ALL_PAGES.map((page) => { |
89 | | - let score = 0; |
90 | | - const label = page.label.toLowerCase(); |
91 | | - const searchText = `${label} ${page.description || ''} ${page.keywords?.join(' ') || ''}`.toLowerCase(); |
92 | | -
|
93 | | - // Exact label match (highest priority) |
94 | | - if (label === normalizedQuery) score += 1000; |
95 | | -
|
96 | | - // Label starts with query |
97 | | - if (label.startsWith(normalizedQuery)) score += 500; |
98 | | -
|
99 | | - // Label contains query |
100 | | - if (label.includes(normalizedQuery)) score += 200; |
101 | | -
|
102 | | - // All tokens found |
103 | | - if (tokens.every((token) => searchText.includes(token))) score += 100; |
104 | | -
|
105 | | - // Keywords match |
106 | | - if (page.keywords) { |
107 | | - tokens.forEach((token) => { |
108 | | - if (page.keywords!.some((kw) => kw.toLowerCase().includes(token))) score += 50; |
109 | | - }); |
110 | | - } |
111 | | -
|
112 | | - // Description match |
113 | | - if (page.description && tokens.some((token) => page.description!.toLowerCase().includes(token))) { |
114 | | - score += 25; |
115 | | - } |
116 | | -
|
117 | | - // Boost for bookmarked/frequent |
118 | | - if (bookmarkedHrefs.has(page.href)) score += 15; |
119 | | - if (frequentHrefs.has(page.href)) score += 10; |
120 | | -
|
121 | | - return { ...page, score }; |
122 | | - }) |
123 | | - .filter((page) => page.score > 10) |
124 | | - .sort((a, b) => b.score - a.score); |
125 | | -
|
126 | | - // If few/no results and query is reasonable length, do fuzzy search |
127 | | - if (results.length < 3 && normalizedQuery.length >= 2 && normalizedQuery.length <= 20) { |
128 | | - const fuzzyResults = ALL_PAGES.map((page) => { |
129 | | - let score = results.find((r) => r.href === page.href)?.score || 0; |
130 | | - const label = page.label.toLowerCase(); |
131 | | - const words = label.split(/\s+/); |
132 | | - let hasMeaningfulMatch = false; |
133 | | -
|
134 | | - // Acronym match (e.g., "dc" matches "DNS Check") |
135 | | - const acronym = words.map((w) => w[0]).join(''); |
136 | | - if (acronym === normalizedQuery) { |
137 | | - score += 300; |
138 | | - hasMeaningfulMatch = true; |
139 | | - } |
140 | | - if (acronym.startsWith(normalizedQuery)) { |
141 | | - score += 150; |
142 | | - hasMeaningfulMatch = true; |
143 | | - } |
| 59 | + const results = searchPages(q, ALL_PAGES, bookmarkedHrefs, frequentHrefs, 8); |
144 | 60 |
|
145 | | - // Fuzzy string match with Levenshtein distance |
146 | | - tokens.forEach((token) => { |
147 | | - if (token.length < 2) return; // Skip single chars |
148 | | -
|
149 | | - const distance = levenshtein(token, label); |
150 | | - const similarity = 1 - distance / Math.max(token.length, label.length); |
151 | | - if (similarity > 0.65) { |
152 | | - score += Math.floor(similarity * 100); |
153 | | - hasMeaningfulMatch = true; |
154 | | - } |
155 | | -
|
156 | | - // Check against individual words |
157 | | - words.forEach((word) => { |
158 | | - if (word.length < 2) return; |
159 | | - const wordDist = levenshtein(token, word); |
160 | | - const wordSim = 1 - wordDist / Math.max(token.length, word.length); |
161 | | - if (wordSim > 0.7) { |
162 | | - score += Math.floor(wordSim * 80); |
163 | | - hasMeaningfulMatch = true; |
164 | | - } |
165 | | - }); |
166 | | -
|
167 | | - // Check keywords with fuzzy match |
168 | | - page.keywords?.forEach((keyword) => { |
169 | | - const kwDist = levenshtein(token, keyword.toLowerCase()); |
170 | | - const kwSim = 1 - kwDist / Math.max(token.length, keyword.length); |
171 | | - if (kwSim > 0.75) { |
172 | | - score += Math.floor(kwSim * 60); |
173 | | - hasMeaningfulMatch = true; |
174 | | - } |
175 | | - }); |
176 | | - }); |
177 | | -
|
178 | | - // Partial word boundary match (only if token is significant portion of word) |
179 | | - tokens.forEach((token) => { |
180 | | - if (token.length >= 3) { |
181 | | - words.forEach((word) => { |
182 | | - if (word.includes(token) && token.length / word.length > 0.6) { |
183 | | - score += 40; |
184 | | - hasMeaningfulMatch = true; |
185 | | - } |
186 | | - }); |
187 | | - } |
188 | | - }); |
189 | | -
|
190 | | - // Boost for bookmarked/frequent (but don't count as meaningful match) |
191 | | - if (bookmarkedHrefs.has(page.href)) score += 15; |
192 | | - if (frequentHrefs.has(page.href)) score += 10; |
193 | | -
|
194 | | - return { ...page, score, hasMeaningfulMatch }; |
195 | | - }) |
196 | | - .filter((page) => page.score > 100 && page.hasMeaningfulMatch) // Must have meaningful match |
197 | | - .sort((a, b) => b.score - a.score); |
198 | | -
|
199 | | - return fuzzyResults.slice(0, 8); |
200 | | - } |
201 | | -
|
202 | | - return results.slice(0, 8); |
| 61 | + // If no results, show suggestions |
| 62 | + return results.length > 0 ? results : getSuggested().slice(0, 8); |
203 | 63 | } |
204 | 64 |
|
205 | 65 | // Using a separate internal function to avoid reactivity warnings |
|
0 commit comments