1package parser
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/bnema/dumber/internal/db"
9)
10
11// HistorySearcher provides history search integration with fuzzy matching.
12type HistorySearcher struct {
13 provider HistoryProvider
14 fuzzyMatcher *FuzzyMatcher
15 fuzzyConfig *FuzzyConfig
16}
17
18// NewHistorySearcher creates a new HistorySearcher.
19func NewHistorySearcher(provider HistoryProvider, fuzzyConfig *FuzzyConfig) *HistorySearcher {
20 if fuzzyConfig == nil {
21 fuzzyConfig = DefaultFuzzyConfig()
22 }
23
24 return &HistorySearcher{
25 provider: provider,
26 fuzzyMatcher: NewFuzzyMatcher(fuzzyConfig),
27 fuzzyConfig: fuzzyConfig,
28 }
29}
30
31// SearchWithFuzzy performs fuzzy search on history entries.
32func (hs *HistorySearcher) SearchWithFuzzy(query string, options ...SearchOption) (*HistorySearchResult, error) {
33 if query == "" {
34 return &HistorySearchResult{}, nil
35 }
36
37 // Apply search options
38 searchConfig := &SearchConfig{
39 Limit: hs.fuzzyConfig.MaxResults,
40 MinSimilarityThreshold: hs.fuzzyConfig.MinSimilarityThreshold,
41 IncludeExactMatches: true,
42 IncludeFuzzyMatches: true,
43 SortByRelevance: true,
44 }
45
46 for _, opt := range options {
47 opt(searchConfig)
48 }
49
50 startTime := time.Now()
51
52 // Get all history for fuzzy search
53 allHistory, err := hs.provider.GetAllHistory()
54 if err != nil {
55 return nil, fmt.Errorf("failed to retrieve history: %w", err)
56 }
57
58 result := &HistorySearchResult{
59 Query: query,
60 TotalEntries: len(allHistory),
61 SearchTime: 0, // Will be set below
62 }
63
64 // Perform exact search first if requested
65 if searchConfig.IncludeExactMatches {
66 exactMatches, err := hs.provider.SearchHistory(query, searchConfig.Limit)
67 if err == nil && len(exactMatches) > 0 {
68 // Convert to FuzzyMatch format with perfect scores
69 for _, entry := range exactMatches {
70 match := FuzzyMatch{
71 HistoryEntry: entry,
72 Score: 1.0,
73 URLScore: 1.0,
74 TitleScore: 1.0,
75 RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
76 VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
77 MatchedField: "exact",
78 }
79 result.ExactMatches = append(result.ExactMatches, match)
80 }
81 }
82 }
83
84 // Perform fuzzy search if requested
85 if searchConfig.IncludeFuzzyMatches {
86 // Create temporary config with search options
87 tempConfig := *hs.fuzzyConfig
88 tempConfig.MinSimilarityThreshold = searchConfig.MinSimilarityThreshold
89 tempConfig.MaxResults = searchConfig.Limit
90
91 tempMatcher := NewFuzzyMatcher(&tempConfig)
92 fuzzyMatches := tempMatcher.SearchHistory(query, allHistory)
93
94 // Filter out matches that are already in exact matches
95 exactURLs := make(map[string]bool)
96 for _, exact := range result.ExactMatches {
97 exactURLs[exact.HistoryEntry.Url] = true
98 }
99
100 for _, match := range fuzzyMatches {
101 if !exactURLs[match.HistoryEntry.Url] {
102 result.FuzzyMatches = append(result.FuzzyMatches, match)
103 }
104 }
105 }
106
107 // Combine and sort results if requested
108 if searchConfig.SortByRelevance {
109 result.RankedMatches = hs.combineAndRankMatches(result.ExactMatches, result.FuzzyMatches, query)
110 }
111
112 // Limit final results
113 if len(result.RankedMatches) > searchConfig.Limit {
114 result.RankedMatches = result.RankedMatches[:searchConfig.Limit]
115 }
116
117 result.MatchCount = len(result.RankedMatches)
118 result.SearchTime = time.Since(startTime)
119
120 return result, nil
121}
122
123// SearchByDomain searches history entries by domain with fuzzy matching.
124func (hs *HistorySearcher) SearchByDomain(domain string, options ...SearchOption) (*HistorySearchResult, error) {
125 // Get all history entries
126 allHistory, err := hs.provider.GetAllHistory()
127 if err != nil {
128 return nil, fmt.Errorf("failed to retrieve history: %w", err)
129 }
130
131 // Filter by domain and apply fuzzy matching
132 domainEntries := make([]*db.History, 0)
133 domain = strings.ToLower(domain)
134
135 for _, entry := range allHistory {
136 entryDomain := hs.extractDomain(entry.Url)
137 if strings.Contains(strings.ToLower(entryDomain), domain) {
138 domainEntries = append(domainEntries, entry)
139 }
140 }
141
142 // If we have domain matches, rank them by fuzzy score
143 matches := make([]FuzzyMatch, 0)
144 for _, entry := range domainEntries {
145 match := hs.fuzzyMatcher.matchHistoryEntry(domain, entry)
146 matches = append(matches, match)
147 }
148
149 // Sort by score
150 matches = hs.fuzzyMatcher.RankMatches(matches, domain)
151
152 return &HistorySearchResult{
153 Query: domain,
154 TotalEntries: len(allHistory),
155 MatchCount: len(matches),
156 RankedMatches: matches,
157 SearchTime: time.Since(time.Now()),
158 }, nil
159}
160
161// SearchByTimeRange searches history entries within a time range with fuzzy matching.
162func (hs *HistorySearcher) SearchByTimeRange(query string, startTime, endTime time.Time) (*HistorySearchResult, error) {
163 // Get all history entries
164 allHistory, err := hs.provider.GetAllHistory()
165 if err != nil {
166 return nil, fmt.Errorf("failed to retrieve history: %w", err)
167 }
168
169 // Filter by time range
170 filteredHistory := make([]*db.History, 0)
171 for _, entry := range allHistory {
172 if entry.LastVisited.Valid {
173 visitTime := entry.LastVisited.Time
174 if visitTime.After(startTime) && visitTime.Before(endTime) {
175 filteredHistory = append(filteredHistory, entry)
176 }
177 }
178 }
179
180 // Perform fuzzy search on filtered entries
181 matches := hs.fuzzyMatcher.SearchHistory(query, filteredHistory)
182
183 return &HistorySearchResult{
184 Query: query,
185 TotalEntries: len(filteredHistory),
186 MatchCount: len(matches),
187 RankedMatches: matches,
188 SearchTime: time.Since(time.Now()),
189 }, nil
190}
191
192// GetTopVisited returns the most visited sites with optional fuzzy filtering.
193func (hs *HistorySearcher) GetTopVisited(limit int, filterQuery string) ([]FuzzyMatch, error) {
194 allHistory, err := hs.provider.GetAllHistory()
195 if err != nil {
196 return nil, fmt.Errorf("failed to retrieve history: %w", err)
197 }
198
199 // Sort by visit count
200 matches := make([]FuzzyMatch, 0)
201 for _, entry := range allHistory {
202 // Apply filter if provided
203 if filterQuery != "" {
204 match := hs.fuzzyMatcher.matchHistoryEntry(filterQuery, entry)
205 if match.Score < hs.fuzzyConfig.MinSimilarityThreshold {
206 continue
207 }
208 matches = append(matches, match)
209 } else {
210 // No filter, just create basic match with visit score
211 match := FuzzyMatch{
212 HistoryEntry: entry,
213 Score: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
214 VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
215 RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
216 MatchedField: "visit_count",
217 }
218 matches = append(matches, match)
219 }
220 }
221
222 // Sort by visit count (descending)
223 for i := 0; i < len(matches); i++ {
224 for j := i + 1; j < len(matches); j++ {
225 iVisits := int64(0)
226 jVisits := int64(0)
227 if matches[i].HistoryEntry.VisitCount.Valid {
228 iVisits = matches[i].HistoryEntry.VisitCount.Int64
229 }
230 if matches[j].HistoryEntry.VisitCount.Valid {
231 jVisits = matches[j].HistoryEntry.VisitCount.Int64
232 }
233 if jVisits > iVisits {
234 matches[i], matches[j] = matches[j], matches[i]
235 }
236 }
237 }
238
239 // Limit results
240 if len(matches) > limit {
241 matches = matches[:limit]
242 }
243
244 return matches, nil
245}
246
247// GetRecentWithFuzzy returns recent entries with optional fuzzy filtering.
248func (hs *HistorySearcher) GetRecentWithFuzzy(limit int, filterQuery string) ([]FuzzyMatch, error) {
249 recentHistory, err := hs.provider.GetRecentHistory(limit * 2) // Get more to filter
250 if err != nil {
251 return nil, fmt.Errorf("failed to retrieve recent history: %w", err)
252 }
253
254 if filterQuery == "" {
255 // No filter, convert to FuzzyMatch format
256 matches := make([]FuzzyMatch, 0, len(recentHistory))
257 for _, entry := range recentHistory {
258 match := FuzzyMatch{
259 HistoryEntry: entry,
260 Score: 1.0, // Recent entries get full score
261 RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
262 VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
263 MatchedField: "recent",
264 }
265 matches = append(matches, match)
266 }
267
268 if len(matches) > limit {
269 matches = matches[:limit]
270 }
271
272 return matches, nil
273 }
274
275 // Apply fuzzy filter
276 matches := hs.fuzzyMatcher.SearchHistory(filterQuery, recentHistory)
277 if len(matches) > limit {
278 matches = matches[:limit]
279 }
280
281 return matches, nil
282}
283
284// combineAndRankMatches combines exact and fuzzy matches with proper ranking.
285func (hs *HistorySearcher) combineAndRankMatches(exactMatches, fuzzyMatches []FuzzyMatch, query string) []FuzzyMatch {
286 combined := make([]FuzzyMatch, 0, len(exactMatches)+len(fuzzyMatches))
287
288 // Add exact matches first (they get priority)
289 combined = append(combined, exactMatches...)
290
291 // Add fuzzy matches
292 combined = append(combined, fuzzyMatches...)
293
294 // Re-rank all matches
295 return hs.fuzzyMatcher.RankMatches(combined, query)
296}
297
298// extractDomain extracts domain from URL for domain-based searches.
299func (hs *HistorySearcher) extractDomain(url string) string {
300 // Simple domain extraction
301 domain := url
302 if strings.HasPrefix(domain, "http://") {
303 domain = domain[7:]
304 } else if strings.HasPrefix(domain, "https://") {
305 domain = domain[8:]
306 }
307
308 // Remove path
309 if idx := strings.Index(domain, "/"); idx != -1 {
310 domain = domain[:idx]
311 }
312
313 // Remove port
314 if idx := strings.LastIndex(domain, ":"); idx != -1 {
315 domain = domain[:idx]
316 }
317
318 return domain
319}
320
321// HistorySearchResult represents the result of a history search operation.
322type HistorySearchResult struct {
323 Query string `json:"query"`
324 TotalEntries int `json:"total_entries"`
325 MatchCount int `json:"match_count"`
326 ExactMatches []FuzzyMatch `json:"exact_matches,omitempty"`
327 FuzzyMatches []FuzzyMatch `json:"fuzzy_matches,omitempty"`
328 RankedMatches []FuzzyMatch `json:"ranked_matches"`
329 SearchTime time.Duration `json:"search_time"`
330}
331
332// SearchConfig holds configuration for history search operations.
333type SearchConfig struct {
334 Limit int `json:"limit"`
335 MinSimilarityThreshold float64 `json:"min_similarity_threshold"`
336 IncludeExactMatches bool `json:"include_exact_matches"`
337 IncludeFuzzyMatches bool `json:"include_fuzzy_matches"`
338 SortByRelevance bool `json:"sort_by_relevance"`
339}
340
341// SearchOption is a functional option for configuring search operations.
342type SearchOption func(*SearchConfig)
343
344// WithLimit sets the maximum number of results to return.
345func WithLimit(limit int) SearchOption {
346 return func(config *SearchConfig) {
347 config.Limit = limit
348 }
349}
350
351// WithSimilarityThreshold sets the minimum similarity threshold for matches.
352func WithSimilarityThreshold(threshold float64) SearchOption {
353 return func(config *SearchConfig) {
354 config.MinSimilarityThreshold = threshold
355 }
356}
357
358// WithExactMatches enables/disables exact match inclusion.
359func WithExactMatches(include bool) SearchOption {
360 return func(config *SearchConfig) {
361 config.IncludeExactMatches = include
362 }
363}
364
365// WithFuzzyMatches enables/disables fuzzy match inclusion.
366func WithFuzzyMatches(include bool) SearchOption {
367 return func(config *SearchConfig) {
368 config.IncludeFuzzyMatches = include
369 }
370}
371
372// WithRelevanceSort enables/disables relevance-based sorting.
373func WithRelevanceSort(enable bool) SearchOption {
374 return func(config *SearchConfig) {
375 config.SortByRelevance = enable
376 }
377}
378
379// GetBestMatch returns the single best match for a query.
380func (hs *HistorySearcher) GetBestMatch(query string) (*FuzzyMatch, error) {
381 result, err := hs.SearchWithFuzzy(query, WithLimit(1))
382 if err != nil {
383 return nil, err
384 }
385
386 if len(result.RankedMatches) == 0 {
387 return nil, nil
388 }
389
390 return &result.RankedMatches[0], nil
391}
392
393// GetMatchesByScore returns matches above a specific score threshold.
394func (hs *HistorySearcher) GetMatchesByScore(query string, minScore float64) ([]FuzzyMatch, error) {
395 result, err := hs.SearchWithFuzzy(query, WithSimilarityThreshold(minScore))
396 if err != nil {
397 return nil, err
398 }
399
400 return result.RankedMatches, nil
401}
402
403// UpdateFuzzyConfig updates the fuzzy matching configuration.
404func (hs *HistorySearcher) UpdateFuzzyConfig(config *FuzzyConfig) {
405 hs.fuzzyConfig = config
406 hs.fuzzyMatcher = NewFuzzyMatcher(config)
407}