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
100 matches, err := uc.historyRepo.Search(ctx, input.Query, limit)
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)
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// GetRecentByDomain retrieves recent history entries for a canonical domain. A
128// zero limit means all matching entries; negative limits use the default page
129// size. Positive limits are capped to keep WebUI-originated queries bounded.
130func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
131 limit = clampOptionalLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit)
132 domain, err := canonicalHistoryDomain(domain)
133 if err != nil {
134 return nil, err
135 }
136
137 entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset)
138 if err != nil {
139 return nil, fmt.Errorf("failed to get recent history by domain: %w", err)
140 }
141 return entries, nil
142}
143
144// canonicalHistoryDomain normalizes a domain and rejects empty values before
145// the domain reaches exact-match persistence queries.
146func canonicalHistoryDomain(domain string) (string, error) {
147 domain = domainurl.CanonicalDomain(domain)
148 if domain == "" {
149 return "", fmt.Errorf("domain is required")
150 }
151 return domain, nil
152}
153
154// GetRecentWindow retrieves a row-bounded history window ending before the given cursor.
155func (uc *SearchHistoryUseCase) GetRecentWindow(
156 ctx context.Context,
157 before time.Time,
158 beforeID int64,
159 domain string,
160) (*entity.HistoryWindow, error) {
161 if beforeID < 0 {
162 return nil, fmt.Errorf("beforeID must be non-negative")
163 }
164 hasBefore := !before.IsZero()
165 hasBeforeID := beforeID != 0
166 if hasBefore != hasBeforeID {
167 return nil, fmt.Errorf("before and beforeID must be provided together")
168 }
169 if !hasBefore {
170 before = time.Now()
171 }
172 before = before.UTC()
173 after := before.Add(-historyWindowDuration)
174 queryLimit := historyWindowRowLimit + 1
175
176 var (
177 entries []*entity.HistoryEntry
178 err error
179 )
180 originalDomain := domain
181 domain = domainurl.CanonicalDomain(domain)
182 if strings.TrimSpace(originalDomain) != "" && domain == "" {
183 return nil, fmt.Errorf("domain is required")
184 }
185 if domain != "" {
186 entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, beforeID, queryLimit)
187 } else {
188 entries, err = uc.historyRepo.GetRecentWindow(ctx, before, beforeID, queryLimit)
189 }
190 if err != nil {
191 return nil, fmt.Errorf("failed to get recent history window: %w", err)
192 }
193
194 hasMore := len(entries) > historyWindowRowLimit
195 if hasMore {
196 entries = entries[:historyWindowRowLimit]
197 }
198 var cursorLastVisited time.Time
199 var cursorID int64
200 if len(entries) > 0 {
201 last := entries[len(entries)-1]
202 if last != nil {
203 cursorLastVisited = last.LastVisited.UTC()
204 cursorID = last.ID
205 }
206 }
207
208 return &entity.HistoryWindow{
209 Entries: entries,
210 Before: before,
211 After: after,
212 CursorLastVisited: cursorLastVisited,
213 CursorID: cursorID,
214 HasMore: hasMore,
215 }, nil
216}
217
218// GetRecentSince retrieves history entries visited within the last N days.
219// If days is 0, returns all history entries.
220// If days is negative, defaults to 30 days.
221func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
222 if days < 0 {
223 days = 30 // Default to 30 days for negative values
224 }
225
226 var entries []*entity.HistoryEntry
227 var err error
228
229 switch days {
230 case 0:
231 entries, err = uc.historyRepo.GetAllRecentHistory(ctx)
232 default:
233 entries, err = uc.historyRepo.GetRecentSince(ctx, days)
234 }
235
236 if err != nil {
237 return nil, fmt.Errorf("failed to get recent history: %w", err)
238 }
239
240 return entries, nil
241}
242
243// GetMostVisited retrieves history entries sorted by visit count within the last N days.
244// If days is 0, returns all history entries sorted by visit count.
245// If days is negative, defaults to 30 days.
246func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
247 if days < 0 {
248 days = 30 // Default to 30 days for negative values
249 }
250
251 var entries []*entity.HistoryEntry
252 var err error
253
254 switch days {
255 case 0:
256 entries, err = uc.historyRepo.GetAllMostVisited(ctx)
257 default:
258 entries, err = uc.historyRepo.GetMostVisited(ctx, days)
259 }
260
261 if err != nil {
262 return nil, fmt.Errorf("failed to get most visited history: %w", err)
263 }
264
265 return entries, nil
266}
267
268// FindByURL retrieves a history entry by its URL.
269func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
270 log := logging.FromContext(ctx)
271 log.Debug().Str("url", url).Msg("finding history entry by URL")
272
273 entry, err := uc.historyRepo.FindByURL(ctx, url)
274 if err != nil {
275 return nil, fmt.Errorf("failed to find history entry: %w", err)
276 }
277
278 if entry == nil {
279 log.Debug().Str("url", url).Msg("history entry not found")
280 }
281
282 return entry, nil
283}
284
285// ClearOlderThan deletes history entries older than the specified time.
286func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.Time) error {
287 log := logging.FromContext(ctx)
288 log.Debug().Time("before", before).Msg("clearing old history")
289
290 release, err := uc.beginHistoryMutation(ctx)
291 if err != nil {
292 return fmt.Errorf("failed to prepare history mutation: %w", err)
293 }
294 release = onceRelease(release)
295 defer release()
296
297 if err := uc.historyRepo.DeleteOlderThan(ctx, before); err != nil {
298 return fmt.Errorf("failed to clear history: %w", err)
299 }
300 release()
301
302 uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
303 log.Info().Time("before", before).Msg("old history cleared")
304 return nil
305}
306
307// ClearRange deletes history entries inside a named recent range.
308func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
309 cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
310 if !ok {
311 return fmt.Errorf("unknown history delete range: %q", rangeID)
312 }
313 if all {
314 return uc.ClearAll(ctx)
315 }
316
317 log := logging.FromContext(ctx)
318 log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range")
319 release, err := uc.beginHistoryMutation(ctx)
320 if err != nil {
321 return fmt.Errorf("failed to prepare history mutation: %w", err)
322 }
323 release = onceRelease(release)
324 defer release()
325
326 if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil {
327 return fmt.Errorf("failed to clear history range: %w", err)
328 }
329 release()
330 uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
331 log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared")
332 return nil
333}
334
335// ClearAll deletes all history entries.
336func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
337 log := logging.FromContext(ctx)
338 log.Debug().Msg("clearing all history")
339
340 release, err := uc.beginHistoryMutation(ctx)
341 if err != nil {
342 return fmt.Errorf("failed to prepare history mutation: %w", err)
343 }
344 release = onceRelease(release)
345 defer release()
346
347 if err := uc.historyRepo.DeleteAll(ctx); err != nil {
348 return fmt.Errorf("failed to clear all history: %w", err)
349 }
350 release()
351
352 uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false)
353 log.Info().Msg("all history cleared")
354 return nil
355}
356
357// Delete removes a single history entry by ID.
358func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
359 log := logging.FromContext(ctx)
360 log.Debug().Int64("id", id).Msg("deleting history entry")
361
362 release, err := uc.beginHistoryMutation(ctx)
363 if err != nil {
364 return fmt.Errorf("failed to prepare history mutation: %w", err)
365 }
366 release = onceRelease(release)
367 defer release()
368
369 if err := uc.historyRepo.Delete(ctx, id); err != nil {
370 return fmt.Errorf("failed to delete history entry: %w", err)
371 }
372 release()
373
374 uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 1, true)
375 log.Debug().Int64("id", id).Msg("history entry deleted")
376 return nil
377}
378
379// DeleteByDomain removes all history entries for a domain.
380func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {
381 domain, err := canonicalHistoryDomain(domain)
382 if err != nil {
383 return err
384 }
385
386 log := logging.FromContext(ctx)
387 log.Debug().Str("domain", domain).Msg("deleting history by domain")
388
389 release, err := uc.beginHistoryMutation(ctx)
390 if err != nil {
391 return fmt.Errorf("failed to prepare history mutation: %w", err)
392 }
393 release = onceRelease(release)
394 defer release()
395
396 if err := uc.historyRepo.DeleteByDomain(ctx, domain); err != nil {
397 return fmt.Errorf("failed to delete history by domain: %w", err)
398 }
399 release()
400
401 uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 0, false)
402 log.Info().Str("domain", domain).Msg("history deleted for domain")
403 return nil
404}
405
406func onceRelease(release func()) func() {
407 var once sync.Once
408 return func() {
409 once.Do(release)
410 }
411}
412
413func (uc *SearchHistoryUseCase) beginHistoryMutation(ctx context.Context) (func(), error) {
414 uc.mutationCoordinatorMu.RLock()
415 coordinator := uc.mutationCoordinator
416 uc.mutationCoordinatorMu.RUnlock()
417 if coordinator == nil {
418 return func() {}, nil
419 }
420 release, err := coordinator.BeginHistoryMutation(ctx)
421 if err != nil {
422 if release != nil {
423 release()
424 }
425 return nil, err
426 }
427 if release == nil {
428 release = func() {}
429 }
430 return release, nil
431}
432
433func (uc *SearchHistoryUseCase) publishHistoryChange(
434 ctx context.Context,
435 reason dto.HistoryChangeReason,
436 deleteCount int,
437 deleteCountKnown bool,
438) {
439 change := dto.HistoryChange{
440 Reasons: []dto.HistoryChangeReason{reason},
441 DeleteCount: deleteCount,
442 DeleteCountKnown: deleteCountKnown,
443 ChangedAt: time.Now(),
444 }
445 sink := uc.historyChangeSink()
446 sink.OnHistoryChanged(ctx, change)
447}
448
449func (uc *SearchHistoryUseCase) historyChangeSink() port.HistoryChangeSink {
450 uc.changeSinkMu.RLock()
451 sink := uc.changeSink
452 uc.changeSinkMu.RUnlock()
453 return normalizeHistoryChangeSink(sink)
454}
455
456// GetDomainStats retrieves per-domain visit statistics.
457func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
458 log := logging.FromContext(ctx)
459 log.Debug().Int("limit", limit).Msg("getting domain stats")
460
461 limit = clampPositiveLimit(limit, defaultDomainStatsLimit, maxDomainStatsLimit)
462
463 return uc.historyRepo.GetDomainStats(ctx, limit)
464}
465
466// clampOptionalLimit normalizes an optional limit where zero remains a valid
467// "no limit" value, negatives fall back to defaultLimit, and values above
468// maxLimit are capped. Use clampPositiveLimit instead when zero should be
469// treated as invalid input.
470func clampOptionalLimit(limit, defaultLimit, maxLimit int) int {
471 if limit < 0 {
472 return defaultLimit
473 }
474 if limit > maxLimit {
475 return maxLimit
476 }
477 return limit
478}
479
480// clampPositiveLimit normalizes a required positive limit. Values less than or
481// equal to zero fall back to defaultLimit, values above maxLimit are capped,
482// and the result stays within the expected positive range. Use this for
483// required limits like domain stats, and clampOptionalLimit when zero has its
484// own meaning.
485func clampPositiveLimit(limit, defaultLimit, maxLimit int) int {
486 if limit <= 0 {
487 return defaultLimit
488 }
489 if limit > maxLimit {
490 return maxLimit
491 }
492 return limit
493}
494
495// GetStats retrieves lightweight aggregate history statistics.
496func (uc *SearchHistoryUseCase) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
497 stats, err := uc.historyRepo.GetStats(ctx)
498 if err != nil {
499 return nil, fmt.Errorf("failed to get history stats: %w", err)
500 }
501 return stats, nil
502}
503
504// GetAnalytics retrieves aggregated history analytics for the homepage.
505func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
506 log := logging.FromContext(ctx)
507 log.Debug().Msg("getting history analytics")
508
509 // Get basic stats
510 stats, err := uc.historyRepo.GetStats(ctx)
511 if err != nil {
512 return nil, fmt.Errorf("failed to get history stats: %w", err)
513 }
514
515 // Get top domains
516 domains, err := uc.historyRepo.GetDomainStats(ctx, 10)
517 if err != nil {
518 return nil, fmt.Errorf("failed to get domain stats: %w", err)
519 }
520
521 // Get daily visits for last 30 days
522 dailyVisits, err := uc.historyRepo.GetDailyVisitCount(ctx, "-30 days")
523 if err != nil {
524 return nil, fmt.Errorf("failed to get daily visits: %w", err)
525 }
526
527 // Get hourly distribution
528 hourlyDist, err := uc.historyRepo.GetHourlyDistribution(ctx)
529 if err != nil {
530 return nil, fmt.Errorf("failed to get hourly distribution: %w", err)
531 }
532
533 return &entity.HistoryAnalytics{
534 TotalEntries: stats.TotalEntries,
535 TotalVisits: stats.TotalVisits,
536 UniqueDays: stats.UniqueDays,
537 TopDomains: domains,
538 DailyVisits: dailyVisits,
539 HourlyDistribution: hourlyDist,
540 }, nil
541}