1package usecase
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"
13 historydomain "github.com/bnema/dumber/internal/domain/history"
14 "github.com/bnema/dumber/internal/domain/repository"
15 domainurl "github.com/bnema/dumber/internal/domain/url"
16 "github.com/bnema/dumber/internal/logging"
17)
18
19// Compile-time checks: SearchHistoryUseCase must satisfy the application
20// history ports used by WebUI handlers and the native GTK history sidebar.
21var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)
22var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil)
23
24const (
25 historyWindowDuration = 24 * time.Hour
26 historyWindowRowLimit = 100
27
28 defaultHistoryPageLimit = 50
29 maxHistoryPageLimit = 500
30 defaultHistorySearchLimit = 20
31 maxHistorySearchLimit = 100
32 defaultDomainStatsLimit = 20
33 maxDomainStatsLimit = 100
34)
35
36// SearchHistoryUseCase handles history search and retrieval operations.
37type SearchHistoryUseCase struct {
38 historyRepo repository.HistoryRepository
39 changeSinkMu sync.RWMutex
40 changeSink port.HistoryChangeSink
41 mutationCoordinatorMu sync.RWMutex
42 mutationCoordinator port.HistoryMutationCoordinator
43}
44
45// NewSearchHistoryUseCase creates a new history search use case.
46func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository, changeSink ...port.HistoryChangeSink) *SearchHistoryUseCase {
47 sink := port.HistoryChangeSink(nil)
48 if len(changeSink) > 0 {
49 sink = changeSink[0]
50 }
51
52 return &SearchHistoryUseCase{
53 historyRepo: historyRepo,
54 changeSink: normalizeHistoryChangeSink(sink),
55 }
56}
57
58// SetHistoryChangeSink sets the sink for persisted history change notifications.
59func (uc *SearchHistoryUseCase) SetHistoryChangeSink(changeSink port.HistoryChangeSink) {
60 if uc == nil {
61 return
62 }
63 changeSink = normalizeHistoryChangeSink(changeSink)
64 uc.changeSinkMu.Lock()
65 uc.changeSink = changeSink
66 uc.changeSinkMu.Unlock()
67}
68
69// SetHistoryMutationCoordinator sets the coordinator used before destructive history mutations.
70func (uc *SearchHistoryUseCase) SetHistoryMutationCoordinator(coordinator port.HistoryMutationCoordinator) {
71 if uc == nil {
72 return
73 }
74 if isNilInterface(coordinator) {
75 coordinator = nil
76 }
77 uc.mutationCoordinatorMu.Lock()
78 uc.mutationCoordinator = coordinator
79 uc.mutationCoordinatorMu.Unlock()
80}
81
82// SearchInput is an alias for dto.HistorySearchInput.
83type SearchInput = dto.HistorySearchInput
84
85// SearchOutput is an alias for dto.HistorySearchOutput.
86type SearchOutput = dto.HistorySearchOutput
87
88// Search performs a full-text search on history entries using SQLite FTS5.
89// Returns only entries that actually match the query terms.
90func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) {
91 log := logging.FromContext(ctx)
92
93 if input.Query == "" {
94 return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil
95 }
96
97 limit := clampPositiveLimit(input.Limit, defaultHistorySearchLimit, maxHistorySearchLimit)
98
99 // Use repository's FTS5 search with the optional age scope applied in SQL.
100 matches, err := uc.historyRepo.Search(ctx, input.Query, limit, historyScopeFromDays(input.MaxAgeDays, time.Now()))
101 if err != nil {
102 return nil, fmt.Errorf("failed to search history: %w", err)
103 }
104
105 log.Debug().
106 Str("query", input.Query).
107 Int("matches", len(matches)).
108 Msg("FTS5 search completed")
109
110 return &SearchOutput{Matches: matches}, nil
111}
112
113// GetRecent retrieves recent history entries. A zero limit means all entries;
114// negative limits retain the historical default page size. Positive limits are
115// capped to keep WebUI-originated queries bounded.
116func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
117 limit = clampOptionalLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)
118
119 entries, err := uc.historyRepo.GetRecent(ctx, limit, offset, repository.HistoryScope{})
120 if err != nil {
121 return nil, fmt.Errorf("failed to get recent history: %w", err)
122 }
123
124 return entries, nil
125}
126
127// GetRecentWithin retrieves up to limit recent history entries visited within
128// the last maxAgeDays days. Zero or negative maxAgeDays means all stored
129// history; a non-positive limit returns no entries. The age filter and limit
130// are applied in SQL.
131func (uc *SearchHistoryUseCase) GetRecentWithin(ctx context.Context, limit, maxAgeDays int) ([]*entity.HistoryEntry, error) {
132 if limit <= 0 {
133 return []*entity.HistoryEntry{}, nil
134 }
135 limit = clampPositiveLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)
136
137 entries, err := uc.historyRepo.GetRecent(ctx, limit, 0, historyScopeFromDays(maxAgeDays, time.Now()))
138 if err != nil {
139 return nil, fmt.Errorf("failed to get recent history: %w", err)
140 }
141
142 return entries, nil
143}
144
145// GetMostVisitedWithin retrieves up to limit most-visited history entries
146// visited within the last maxAgeDays days. Zero or negative maxAgeDays means
147// all stored history; a non-positive limit returns no entries. The age filter
148// and limit are applied in SQL.
149func (uc *SearchHistoryUseCase) GetMostVisitedWithin(ctx context.Context, limit, maxAgeDays int) ([]*entity.HistoryEntry, error) {
150 if limit <= 0 {
151 return []*entity.HistoryEntry{}, nil
152 }
153 limit = clampPositiveLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)
154
155 entries, err := uc.historyRepo.GetMostVisitedWithin(ctx, limit, historyScopeFromDays(maxAgeDays, time.Now()))
156 if err != nil {
157 return nil, fmt.Errorf("failed to get most visited history: %w", err)
158 }
159
160 return entries, nil
161}
162
163// historyScopeFromDays converts an optional max age in days to an explicit
164// history scope. The application boundary normalizes zero and negative values
165// to "no age restriction"; config validation rejects negative
166// omnibox.max_history_days before it reaches this layer.
167func historyScopeFromDays(maxAgeDays int, now time.Time) repository.HistoryScope {
168 if maxAgeDays <= 0 {
169 return repository.HistoryScope{}
170 }
171 return repository.HistoryScope{Cutoff: now.UTC().AddDate(0, 0, -maxAgeDays)}
172}
173
174// GetRecentByDomain retrieves recent history entries for a canonical domain. A
175// zero limit means all matching entries; negative limits use the default page
176// size. Positive limits are capped to keep WebUI-originated queries bounded.
177func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
178 limit = clampOptionalLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)
179 domain, err := canonicalHistoryDomain(domain)
180 if err != nil {
181 return nil, err
182 }
183
184 entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset)
185 if err != nil {
186 return nil, fmt.Errorf("failed to get recent history by domain: %w", err)
187 }
188 return entries, nil
189}
190
191// canonicalHistoryDomain normalizes a domain and rejects empty values before
192// the domain reaches exact-match persistence queries.
193func canonicalHistoryDomain(domain string) (string, error) {
194 domain = domainurl.CanonicalDomain(domain)
195 if domain == "" {
196 return "", fmt.Errorf("domain is required")
197 }
198 return domain, nil
199}
200
201// GetRecentWindow retrieves a row-bounded history window ending before the given cursor.
202func (uc *SearchHistoryUseCase) GetRecentWindow(
203 ctx context.Context,
204 before time.Time,
205 beforeID int64,
206 domain string,
207) (*entity.HistoryWindow, error) {
208 if beforeID < 0 {
209 return nil, fmt.Errorf("beforeID must be non-negative")
210 }
211 hasBefore := !before.IsZero()
212 hasBeforeID := beforeID != 0
213 if hasBefore != hasBeforeID {
214 return nil, fmt.Errorf("before and beforeID must be provided together")
215 }
216 if !hasBefore {
217 before = time.Now()
218 }
219 before = before.UTC()
220 after := before.Add(-historyWindowDuration)
221 queryLimit := historyWindowRowLimit + 1
222
223 var (
224 entries []*entity.HistoryEntry
225 err error
226 )
227 originalDomain := domain
228 domain = domainurl.CanonicalDomain(domain)
229 if strings.TrimSpace(originalDomain) != "" && domain == "" {
230 return nil, fmt.Errorf("domain is required")
231 }
232 if domain != "" {
233 entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, beforeID, queryLimit)
234 } else {
235 entries, err = uc.historyRepo.GetRecentWindow(ctx, before, beforeID, queryLimit)
236 }
237 if err != nil {
238 return nil, fmt.Errorf("failed to get recent history window: %w", err)
239 }
240
241 hasMore := len(entries) > historyWindowRowLimit
242 if hasMore {
243 entries = entries[:historyWindowRowLimit]
244 }
245 var cursorLastVisited time.Time
246 var cursorID int64
247 if len(entries) > 0 {
248 last := entries[len(entries)-1]
249 if last != nil {
250 cursorLastVisited = last.LastVisited.UTC()
251 cursorID = last.ID
252 }
253 }
254
255 return &entity.HistoryWindow{
256 Entries: entries,
257 Before: before,
258 After: after,
259 CursorLastVisited: cursorLastVisited,
260 CursorID: cursorID,
261 HasMore: hasMore,
262 }, nil
263}
264
265// GetRecentSince retrieves history entries visited within the last N days.
266// If days is 0, returns all history entries.
267// If days is negative, defaults to 30 days.
268func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
269 if days < 0 {
270 days = 30 // Default to 30 days for negative values
271 }
272
273 var entries []*entity.HistoryEntry
274 var err error
275
276 switch days {
277 case 0:
278 entries, err = uc.historyRepo.GetAllRecentHistory(ctx)
279 default:
280 entries, err = uc.historyRepo.GetRecentSince(ctx, days)
281 }
282
283 if err != nil {
284 return nil, fmt.Errorf("failed to get recent history: %w", err)
285 }
286
287 return entries, nil
288}
289
290// GetMostVisited retrieves history entries sorted by visit count within the last N days.
291// If days is 0, returns all history entries sorted by visit count.
292// If days is negative, defaults to 30 days.
293func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
294 if days < 0 {
295 days = 30 // Default to 30 days for negative values
296 }
297
298 var entries []*entity.HistoryEntry
299 var err error
300
301 switch days {
302 case 0:
303 entries, err = uc.historyRepo.GetAllMostVisited(ctx)
304 default:
305 entries, err = uc.historyRepo.GetMostVisited(ctx, days)
306 }
307
308 if err != nil {
309 return nil, fmt.Errorf("failed to get most visited history: %w", err)
310 }
311
312 return entries, nil
313}
314
315// FindByURL retrieves a history entry by its URL.
316func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
317 log := logging.FromContext(ctx)
318 log.Debug().Str("url", url).Msg("finding history entry by URL")
319
320 entry, err := uc.historyRepo.FindByURL(ctx, url)
321 if err != nil {
322 return nil, fmt.Errorf("failed to find history entry: %w", err)
323 }
324
325 if entry == nil {
326 log.Debug().Str("url", url).Msg("history entry not found")
327 }
328
329 return entry, nil
330}
331
332// ClearOlderThan deletes history entries older than the specified time.
333func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.Time) error {
334 log := logging.FromContext(ctx)
335 log.Debug().Time("before", before).Msg("clearing old history")
336
337 release, err := uc.beginHistoryMutation(ctx)
338 if err != nil {
339 return fmt.Errorf("failed to prepare history mutation: %w", err)
340 }
341 release = onceRelease(release)
342 defer release()
343
344 if err := uc.historyRepo.DeleteOlderThan(ctx, before); err != nil {
345 return fmt.Errorf("failed to clear history: %w", err)
346 }
347 release()
348
349 uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
350 log.Info().Time("before", before).Msg("old history cleared")
351 return nil
352}
353
354// ClearRange deletes history entries inside a named recent range.
355func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
356 cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
357 if !ok {
358 return fmt.Errorf("unknown history delete range: %q", rangeID)
359 }
360 if all {
361 return uc.ClearAll(ctx)
362 }
363
364 log := logging.FromContext(ctx)
365 log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range")
366 release, err := uc.beginHistoryMutation(ctx)
367 if err != nil {
368 return fmt.Errorf("failed to prepare history mutation: %w", err)
369 }
370 release = onceRelease(release)
371 defer release()
372
373 if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil {
374 return fmt.Errorf("failed to clear history range: %w", err)
375 }
376 release()
377 uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
378 log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared")
379 return nil
380}
381
382// ClearAll deletes all history entries.
383func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
384 log := logging.FromContext(ctx)
385 log.Debug().Msg("clearing all history")
386
387 release, err := uc.beginHistoryMutation(ctx)
388 if err != nil {
389 return fmt.Errorf("failed to prepare history mutation: %w", err)
390 }
391 release = onceRelease(release)
392 defer release()
393
394 if err := uc.historyRepo.DeleteAll(ctx); err != nil {
395 return fmt.Errorf("failed to clear all history: %w", err)
396 }
397 release()
398
399 uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
400 log.Info().Msg("all history cleared")
401 return nil
402}
403
404// Delete removes a single history entry by ID.
405func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
406 log := logging.FromContext(ctx)
407 log.Debug().Int64("id", id).Msg("deleting history entry")
408
409 release, err := uc.beginHistoryMutation(ctx)
410 if err != nil {
411 return fmt.Errorf("failed to prepare history mutation: %w", err)
412 }
413 release = onceRelease(release)
414 defer release()
415
416 if err := uc.historyRepo.Delete(ctx, id); err != nil {
417 return fmt.Errorf("failed to delete history entry: %w", err)
418 }
419 release()
420
421 uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 1, true)
422 log.Debug().Int64("id", id).Msg("history entry deleted")
423 return nil
424}
425
426// DeleteByDomain removes all history entries for a domain.
427func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {
428 domain, err := canonicalHistoryDomain(domain)
429 if err != nil {
430 return err
431 }
432
433 log := logging.FromContext(ctx)
434 log.Debug().Str("domain", domain).Msg("deleting history by domain")
435
436 release, err := uc.beginHistoryMutation(ctx)
437 if err != nil {
438 return fmt.Errorf("failed to prepare history mutation: %w", err)
439 }
440 release = onceRelease(release)
441 defer release()
442
443 if err := uc.historyRepo.DeleteByDomain(ctx, domain); err != nil {
444 return fmt.Errorf("failed to delete history by domain: %w", err)
445 }
446 release()
447
448 uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 0, false)
449 log.Info().Str("domain", domain).Msg("history deleted for domain")
450 return nil
451}
452
453func onceRelease(release func()) func() {
454 var once sync.Once
455 return func() {
456 once.Do(release)
457 }
458}
459
460func (uc *SearchHistoryUseCase) beginHistoryMutation(ctx context.Context) (func(), error) {
461 uc.mutationCoordinatorMu.RLock()
462 coordinator := uc.mutationCoordinator
463 uc.mutationCoordinatorMu.RUnlock()
464 if coordinator == nil {
465 return func() {}, nil
466 }
467 release, err := coordinator.BeginHistoryMutation(ctx)
468 if err != nil {
469 if release != nil {
470 release()
471 }
472 return nil, err
473 }
474 if release == nil {
475 release = func() {}
476 }
477 return release, nil
478}
479
480func (uc *SearchHistoryUseCase) publishHistoryChange(
481 ctx context.Context,
482 reason dto.HistoryChangeReason,
483 deleteCount int,
484 deleteCountKnown bool,
485) {
486 change := dto.HistoryChange{
487 Reasons: []dto.HistoryChangeReason{reason},
488 DeleteCount: deleteCount,
489 DeleteCountKnown: deleteCountKnown,
490 ChangedAt: time.Now(),
491 }
492 sink := uc.historyChangeSink()
493 sink.OnHistoryChanged(ctx, change)
494}
495
496func (uc *SearchHistoryUseCase) historyChangeSink() port.HistoryChangeSink {
497 uc.changeSinkMu.RLock()
498 sink := uc.changeSink
499 uc.changeSinkMu.RUnlock()
500 return normalizeHistoryChangeSink(sink)
501}
502
503// GetDomainStats retrieves per-domain visit statistics.
504func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
505 log := logging.FromContext(ctx)
506 log.Debug().Int("limit", limit).Msg("getting domain stats")
507
508 limit = clampPositiveLimit(limit, defaultDomainStatsLimit, maxDomainStatsLimit)
509
510 return uc.historyRepo.GetDomainStats(ctx, limit)
511}
512
513// clampOptionalLimit normalizes an optional limit where zero remains a valid
514// "no limit" value, negatives fall back to defaultLimit, and values above
515// maxLimit are capped. Use clampPositiveLimit instead when zero should be
516// treated as invalid input.
517func clampOptionalLimit(limit, defaultLimit, maxLimit int) int {
518 if limit < 0 {
519 return defaultLimit
520 }
521 if limit > maxLimit {
522 return maxLimit
523 }
524 return limit
525}
526
527// clampPositiveLimit normalizes a required positive limit. Values less than or
528// equal to zero fall back to defaultLimit, values above maxLimit are capped,
529// and the result stays within the expected positive range. Use this for
530// required limits like domain stats, and clampOptionalLimit when zero has its
531// own meaning.
532func clampPositiveLimit(limit, defaultLimit, maxLimit int) int {
533 if limit <= 0 {
534 return defaultLimit
535 }
536 if limit > maxLimit {
537 return maxLimit
538 }
539 return limit
540}
541
542// GetStats retrieves lightweight aggregate history statistics.
543func (uc *SearchHistoryUseCase) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
544 stats, err := uc.historyRepo.GetStats(ctx)
545 if err != nil {
546 return nil, fmt.Errorf("failed to get history stats: %w", err)
547 }
548 return stats, nil
549}
550
551// GetAnalytics retrieves aggregated history analytics for the homepage.
552func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
553 log := logging.FromContext(ctx)
554 log.Debug().Msg("getting history analytics")
555
556 // Get basic stats
557 stats, err := uc.historyRepo.GetStats(ctx)
558 if err != nil {
559 return nil, fmt.Errorf("failed to get history stats: %w", err)
560 }
561
562 // Get top domains
563 domains, err := uc.historyRepo.GetDomainStats(ctx, 10)
564 if err != nil {
565 return nil, fmt.Errorf("failed to get domain stats: %w", err)
566 }
567
568 // Get daily visits for last 30 days
569 dailyVisits, err := uc.historyRepo.GetDailyVisitCount(ctx, "-30 days")
570 if err != nil {
571 return nil, fmt.Errorf("failed to get daily visits: %w", err)
572 }
573
574 // Get hourly distribution
575 hourlyDist, err := uc.historyRepo.GetHourlyDistribution(ctx)
576 if err != nil {
577 return nil, fmt.Errorf("failed to get hourly distribution: %w", err)
578 }
579
580 return &entity.HistoryAnalytics{
581 TotalEntries: stats.TotalEntries,
582 TotalVisits: stats.TotalVisits,
583 UniqueDays: stats.UniqueDays,
584 TopDomains: domains,
585 DailyVisits: dailyVisits,
586 HourlyDistribution: hourlyDist,
587 }, nil
588}