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 // Apply search options
126 searchConfig := &SearchConfig{
127 Limit: hs.fuzzyConfig.MaxResults,
128 MinSimilarityThreshold: hs.fuzzyConfig.MinSimilarityThreshold,
129 IncludeExactMatches: true,
130 IncludeFuzzyMatches: true,
131 SortByRelevance: true,
132 }
133
134 for _, opt := range options {
135 opt(searchConfig)
136 }
137
138 // Get all history entries
139 allHistory, err := hs.provider.GetAllHistory()
140 if err != nil {
141 return nil, fmt.Errorf("failed to retrieve history: %w", err)
142 }
143
144 // Filter by domain and apply fuzzy matching
145 domainEntries := make([]*db.History, 0)
146 domain = strings.ToLower(domain)
147
148 for _, entry := range allHistory {
149 entryDomain := hs.extractDomain(entry.Url)
150 if strings.Contains(strings.ToLower(entryDomain), domain) {
151 domainEntries = append(domainEntries, entry)
152 }
153 }
154
155 // If we have domain matches, rank them by fuzzy score
156 matches := make([]FuzzyMatch, 0)
157 for _, entry := range domainEntries {
158 match := hs.fuzzyMatcher.matchHistoryEntry(domain, entry)
159 // Apply similarity threshold filter
160 if match.Score >= searchConfig.MinSimilarityThreshold {
161 matches = append(matches, match)
162 }
163 }
164
165 // Sort by score if requested
166 if searchConfig.SortByRelevance {
167 matches = hs.fuzzyMatcher.RankMatches(matches, domain)
168 }
169
170 // Apply limit
171 if searchConfig.Limit > 0 && len(matches) > searchConfig.Limit {
172 matches = matches[:searchConfig.Limit]
173 }
174
175 return &HistorySearchResult{
176 Query: domain,
177 TotalEntries: len(allHistory),
178 MatchCount: len(matches),
179 RankedMatches: matches,
180 SearchTime: 0, // Set by caller if needed
181 }, nil
182}
183
184// SearchByTimeRange searches history entries within a time range with fuzzy matching.
185func (hs *HistorySearcher) SearchByTimeRange(query string, startTime, endTime time.Time) (*HistorySearchResult, error) {
186 // Get all history entries
187 allHistory, err := hs.provider.GetAllHistory()
188 if err != nil {
189 return nil, fmt.Errorf("failed to retrieve history: %w", err)
190 }
191
192 // Filter by time range
193 filteredHistory := make([]*db.History, 0)
194 for _, entry := range allHistory {
195 if entry.LastVisited.Valid {
196 visitTime := entry.LastVisited.Time
197 if visitTime.After(startTime) && visitTime.Before(endTime) {
198 filteredHistory = append(filteredHistory, entry)
199 }
200 }
201 }
202
203 // Perform fuzzy search on filtered entries
204 matches := hs.fuzzyMatcher.SearchHistory(query, filteredHistory)
205
206 return &HistorySearchResult{
207 Query: query,
208 TotalEntries: len(filteredHistory),
209 MatchCount: len(matches),
210 RankedMatches: matches,
211 SearchTime: time.Since(time.Now()),
212 }, nil
213}
214
215// GetTopVisited returns the most visited sites with optional fuzzy filtering.
216func (hs *HistorySearcher) GetTopVisited(limit int, filterQuery string) ([]FuzzyMatch, error) {
217 allHistory, err := hs.provider.GetAllHistory()
218 if err != nil {
219 return nil, fmt.Errorf("failed to retrieve history: %w", err)
220 }
221
222 // Sort by visit count
223 matches := make([]FuzzyMatch, 0)
224 for _, entry := range allHistory {
225 // Apply filter if provided
226 if filterQuery != "" {
227 match := hs.fuzzyMatcher.matchHistoryEntry(filterQuery, entry)
228 if match.Score < hs.fuzzyConfig.MinSimilarityThreshold {
229 continue
230 }
231 matches = append(matches, match)
232 } else {
233 // No filter, just create basic match with visit score
234 match := FuzzyMatch{
235 HistoryEntry: entry,
236 Score: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
237 VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
238 RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
239 MatchedField: "visit_count",
240 }
241 matches = append(matches, match)
242 }
243 }
244
245 // Sort by visit count (descending)
246 for i := 0; i < len(matches); i++ {
247 for j := i + 1; j < len(matches); j++ {
248 iVisits := int64(0)
249 jVisits := int64(0)
250 if matches[i].HistoryEntry.VisitCount.Valid {
251 iVisits = matches[i].HistoryEntry.VisitCount.Int64
252 }
253 if matches[j].HistoryEntry.VisitCount.Valid {
254 jVisits = matches[j].HistoryEntry.VisitCount.Int64
255 }
256 if jVisits > iVisits {
257 matches[i], matches[j] = matches[j], matches[i]
258 }
259 }
260 }
261
262 // Limit results
263 if len(matches) > limit {
264 matches = matches[:limit]
265 }
266
267 return matches, nil
268}
269
270// GetRecentWithFuzzy returns recent entries with optional fuzzy filtering.
271func (hs *HistorySearcher) GetRecentWithFuzzy(limit int, filterQuery string) ([]FuzzyMatch, error) {
272 recentHistory, err := hs.provider.GetRecentHistory(limit * 2) // Get more to filter
273 if err != nil {
274 return nil, fmt.Errorf("failed to retrieve recent history: %w", err)
275 }
276
277 if filterQuery == "" {
278 // No filter, convert to FuzzyMatch format
279 matches := make([]FuzzyMatch, 0, len(recentHistory))
280 for _, entry := range recentHistory {
281 match := FuzzyMatch{
282 HistoryEntry: entry,
283 Score: 1.0, // Recent entries get full score
284 RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
285 VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
286 MatchedField: "recent",
287 }
288 matches = append(matches, match)
289 }
290
291 if len(matches) > limit {
292 matches = matches[:limit]
293 }
294
295 return matches, nil
296 }
297
298 // Apply fuzzy filter
299 matches := hs.fuzzyMatcher.SearchHistory(filterQuery, recentHistory)
300 if len(matches) > limit {
301 matches = matches[:limit]
302 }
303
304 return matches, nil
305}
306
307// combineAndRankMatches combines exact and fuzzy matches with proper ranking.
308func (hs *HistorySearcher) combineAndRankMatches(exactMatches, fuzzyMatches []FuzzyMatch, query string) []FuzzyMatch {
309 combined := make([]FuzzyMatch, 0, len(exactMatches)+len(fuzzyMatches))
310
311 // Add exact matches first (they get priority)
312 combined = append(combined, exactMatches...)
313
314 // Add fuzzy matches
315 combined = append(combined, fuzzyMatches...)
316
317 // Re-rank all matches
318 return hs.fuzzyMatcher.RankMatches(combined, query)
319}
320
321// extractDomain extracts domain from URL for domain-based searches.
322func (hs *HistorySearcher) extractDomain(url string) string {
323 // Simple domain extraction
324 domain := url
325 if strings.HasPrefix(domain, "http://") {
326 domain = domain[7:]
327 } else if strings.HasPrefix(domain, "https://") {
328 domain = domain[8:]
329 }
330
331 // Remove path
332 if idx := strings.Index(domain, "/"); idx != -1 {
333 domain = domain[:idx]
334 }
335
336 // Remove port
337 if idx := strings.LastIndex(domain, ":"); idx != -1 {
338 domain = domain[:idx]
339 }
340
341 return domain
342}
343
344// HistorySearchResult represents the result of a history search operation.
345type HistorySearchResult struct {
346 Query string `json:"query"`
347 TotalEntries int `json:"total_entries"`
348 MatchCount int `json:"match_count"`
349 ExactMatches []FuzzyMatch `json:"exact_matches,omitempty"`
350 FuzzyMatches []FuzzyMatch `json:"fuzzy_matches,omitempty"`
351 RankedMatches []FuzzyMatch `json:"ranked_matches"`
352 SearchTime time.Duration `json:"search_time"`
353}
354
355// SearchConfig holds configuration for history search operations.
356type SearchConfig struct {
357 Limit int `json:"limit"`
358 MinSimilarityThreshold float64 `json:"min_similarity_threshold"`
359 IncludeExactMatches bool `json:"include_exact_matches"`
360 IncludeFuzzyMatches bool `json:"include_fuzzy_matches"`
361 SortByRelevance bool `json:"sort_by_relevance"`
362}
363
364// SearchOption is a functional option for configuring search operations.
365type SearchOption func(*SearchConfig)
366
367// WithLimit sets the maximum number of results to return.
368func WithLimit(limit int) SearchOption {
369 return func(config *SearchConfig) {
370 config.Limit = limit
371 }
372}
373
374// WithSimilarityThreshold sets the minimum similarity threshold for matches.
375func WithSimilarityThreshold(threshold float64) SearchOption {
376 return func(config *SearchConfig) {
377 config.MinSimilarityThreshold = threshold
378 }
379}
380
381// WithExactMatches enables/disables exact match inclusion.
382func WithExactMatches(include bool) SearchOption {
383 return func(config *SearchConfig) {
384 config.IncludeExactMatches = include
385 }
386}
387
388// WithFuzzyMatches enables/disables fuzzy match inclusion.
389func WithFuzzyMatches(include bool) SearchOption {
390 return func(config *SearchConfig) {
391 config.IncludeFuzzyMatches = include
392 }
393}
394
395// WithRelevanceSort enables/disables relevance-based sorting.
396func WithRelevanceSort(enable bool) SearchOption {
397 return func(config *SearchConfig) {
398 config.SortByRelevance = enable
399 }
400}
401
402// GetBestMatch returns the single best match for a query.
403func (hs *HistorySearcher) GetBestMatch(query string) (*FuzzyMatch, error) {
404 result, err := hs.SearchWithFuzzy(query, WithLimit(1))
405 if err != nil {
406 return nil, err
407 }
408
409 if len(result.RankedMatches) == 0 {
410 return nil, nil
411 }
412
413 return &result.RankedMatches[0], nil
414}
415
416// GetMatchesByScore returns matches above a specific score threshold.
417func (hs *HistorySearcher) GetMatchesByScore(query string, minScore float64) ([]FuzzyMatch, error) {
418 result, err := hs.SearchWithFuzzy(query, WithSimilarityThreshold(minScore))
419 if err != nil {
420 return nil, err
421 }
422
423 return result.RankedMatches, nil
424}
425
426// UpdateFuzzyConfig updates the fuzzy matching configuration.
427func (hs *HistorySearcher) UpdateFuzzyConfig(config *FuzzyConfig) {
428 hs.fuzzyConfig = config
429 hs.fuzzyMatcher = NewFuzzyMatcher(config)
430}