package usecase import ( "context" "fmt" "strings" "sync" "time" "github.com/bnema/dumber/internal/application/dto" "github.com/bnema/dumber/internal/application/port" "github.com/bnema/dumber/internal/domain/entity" historydomain "github.com/bnema/dumber/internal/domain/history" "github.com/bnema/dumber/internal/domain/repository" domainurl "github.com/bnema/dumber/internal/domain/url" "github.com/bnema/dumber/internal/logging" ) // Compile-time checks: SearchHistoryUseCase must satisfy the application // history ports used by WebUI handlers and the native GTK history sidebar. var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil) var _ port.HistorySidebarHistory = (*SearchHistoryUseCase)(nil) const ( historyWindowDuration = 24 * time.Hour historyWindowRowLimit = 100 defaultHistoryPageLimit = 50 maxHistoryPageLimit = 500 defaultHistorySearchLimit = 20 maxHistorySearchLimit = 100 defaultDomainStatsLimit = 20 maxDomainStatsLimit = 100 ) // SearchHistoryUseCase handles history search and retrieval operations. type SearchHistoryUseCase struct { historyRepo repository.HistoryRepository changeSinkMu sync.RWMutex changeSink port.HistoryChangeSink mutationCoordinatorMu sync.RWMutex mutationCoordinator port.HistoryMutationCoordinator } // NewSearchHistoryUseCase creates a new history search use case. func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository, changeSink ...port.HistoryChangeSink) *SearchHistoryUseCase { sink := port.HistoryChangeSink(nil) if len(changeSink) > 0 { sink = changeSink[0] } return &SearchHistoryUseCase{ historyRepo: historyRepo, changeSink: normalizeHistoryChangeSink(sink), } } // SetHistoryChangeSink sets the sink for persisted history change notifications. func (uc *SearchHistoryUseCase) SetHistoryChangeSink(changeSink port.HistoryChangeSink) { if uc == nil { return } changeSink = normalizeHistoryChangeSink(changeSink) uc.changeSinkMu.Lock() uc.changeSink = changeSink uc.changeSinkMu.Unlock() } // SetHistoryMutationCoordinator sets the coordinator used before destructive history mutations. func (uc *SearchHistoryUseCase) SetHistoryMutationCoordinator(coordinator port.HistoryMutationCoordinator) { if uc == nil { return } if isNilInterface(coordinator) { coordinator = nil } uc.mutationCoordinatorMu.Lock() uc.mutationCoordinator = coordinator uc.mutationCoordinatorMu.Unlock() } // SearchInput is an alias for dto.HistorySearchInput. type SearchInput = dto.HistorySearchInput // SearchOutput is an alias for dto.HistorySearchOutput. type SearchOutput = dto.HistorySearchOutput // Search performs a full-text search on history entries using SQLite FTS5. // Returns only entries that actually match the query terms. func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) { log := logging.FromContext(ctx) if input.Query == "" { return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil } limit := clampPositiveLimit(input.Limit, defaultHistorySearchLimit, maxHistorySearchLimit) // Use repository's FTS5 search matches, err := uc.historyRepo.Search(ctx, input.Query, limit) if err != nil { return nil, fmt.Errorf("failed to search history: %w", err) } log.Debug(). Str("query", input.Query). Int("matches", len(matches)). Msg("FTS5 search completed") return &SearchOutput{Matches: matches}, nil } // GetRecent retrieves recent history entries. A zero limit means all entries; // negative limits retain the historical default page size. Positive limits are // capped to keep WebUI-originated queries bounded. func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { limit = clampOptionalLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit) entries, err := uc.historyRepo.GetRecent(ctx, limit, offset) if err != nil { return nil, fmt.Errorf("failed to get recent history: %w", err) } return entries, nil } // GetRecentByDomain retrieves recent history entries for a canonical domain. A // zero limit means all matching entries; negative limits use the default page // size. Positive limits are capped to keep WebUI-originated queries bounded. func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { limit = clampOptionalLimit(limit, defaultHistoryPageLimit, maxHistoryPageLimit) domain, err := canonicalHistoryDomain(domain) if err != nil { return nil, err } entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset) if err != nil { return nil, fmt.Errorf("failed to get recent history by domain: %w", err) } return entries, nil } // canonicalHistoryDomain normalizes a domain and rejects empty values before // the domain reaches exact-match persistence queries. func canonicalHistoryDomain(domain string) (string, error) { domain = domainurl.CanonicalDomain(domain) if domain == "" { return "", fmt.Errorf("domain is required") } return domain, nil } // GetRecentWindow retrieves a row-bounded history window ending before the given cursor. func (uc *SearchHistoryUseCase) GetRecentWindow( ctx context.Context, before time.Time, beforeID int64, domain string, ) (*entity.HistoryWindow, error) { if beforeID < 0 { return nil, fmt.Errorf("beforeID must be non-negative") } hasBefore := !before.IsZero() hasBeforeID := beforeID != 0 if hasBefore != hasBeforeID { return nil, fmt.Errorf("before and beforeID must be provided together") } if !hasBefore { before = time.Now() } before = before.UTC() after := before.Add(-historyWindowDuration) queryLimit := historyWindowRowLimit + 1 var ( entries []*entity.HistoryEntry err error ) originalDomain := domain domain = domainurl.CanonicalDomain(domain) if strings.TrimSpace(originalDomain) != "" && domain == "" { return nil, fmt.Errorf("domain is required") } if domain != "" { entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, beforeID, queryLimit) } else { entries, err = uc.historyRepo.GetRecentWindow(ctx, before, beforeID, queryLimit) } if err != nil { return nil, fmt.Errorf("failed to get recent history window: %w", err) } hasMore := len(entries) > historyWindowRowLimit if hasMore { entries = entries[:historyWindowRowLimit] } var cursorLastVisited time.Time var cursorID int64 if len(entries) > 0 { last := entries[len(entries)-1] if last != nil { cursorLastVisited = last.LastVisited.UTC() cursorID = last.ID } } return &entity.HistoryWindow{ Entries: entries, Before: before, After: after, CursorLastVisited: cursorLastVisited, CursorID: cursorID, HasMore: hasMore, }, nil } // GetRecentSince retrieves history entries visited within the last N days. // If days is 0, returns all history entries. // If days is negative, defaults to 30 days. func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { if days < 0 { days = 30 // Default to 30 days for negative values } var entries []*entity.HistoryEntry var err error switch days { case 0: entries, err = uc.historyRepo.GetAllRecentHistory(ctx) default: entries, err = uc.historyRepo.GetRecentSince(ctx, days) } if err != nil { return nil, fmt.Errorf("failed to get recent history: %w", err) } return entries, nil } // GetMostVisited retrieves history entries sorted by visit count within the last N days. // If days is 0, returns all history entries sorted by visit count. // If days is negative, defaults to 30 days. func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { if days < 0 { days = 30 // Default to 30 days for negative values } var entries []*entity.HistoryEntry var err error switch days { case 0: entries, err = uc.historyRepo.GetAllMostVisited(ctx) default: entries, err = uc.historyRepo.GetMostVisited(ctx, days) } if err != nil { return nil, fmt.Errorf("failed to get most visited history: %w", err) } return entries, nil } // FindByURL retrieves a history entry by its URL. func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { log := logging.FromContext(ctx) log.Debug().Str("url", url).Msg("finding history entry by URL") entry, err := uc.historyRepo.FindByURL(ctx, url) if err != nil { return nil, fmt.Errorf("failed to find history entry: %w", err) } if entry == nil { log.Debug().Str("url", url).Msg("history entry not found") } return entry, nil } // ClearOlderThan deletes history entries older than the specified time. func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.Time) error { log := logging.FromContext(ctx) log.Debug().Time("before", before).Msg("clearing old history") release, err := uc.beginHistoryMutation(ctx) if err != nil { return fmt.Errorf("failed to prepare history mutation: %w", err) } release = onceRelease(release) defer release() if err := uc.historyRepo.DeleteOlderThan(ctx, before); err != nil { return fmt.Errorf("failed to clear history: %w", err) } release() uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false) log.Info().Time("before", before).Msg("old history cleared") return nil } // ClearRange deletes history entries inside a named recent range. func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error { cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now()) if !ok { return fmt.Errorf("unknown history delete range: %q", rangeID) } if all { return uc.ClearAll(ctx) } log := logging.FromContext(ctx) log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range") release, err := uc.beginHistoryMutation(ctx) if err != nil { return fmt.Errorf("failed to prepare history mutation: %w", err) } release = onceRelease(release) defer release() if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil { return fmt.Errorf("failed to clear history range: %w", err) } release() uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false) log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared") return nil } // ClearAll deletes all history entries. func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error { log := logging.FromContext(ctx) log.Debug().Msg("clearing all history") release, err := uc.beginHistoryMutation(ctx) if err != nil { return fmt.Errorf("failed to prepare history mutation: %w", err) } release = onceRelease(release) defer release() if err := uc.historyRepo.DeleteAll(ctx); err != nil { return fmt.Errorf("failed to clear all history: %w", err) } release() uc.publishHistoryChange(ctx, dto.HistoryChangeReasonClear, 0, false) log.Info().Msg("all history cleared") return nil } // Delete removes a single history entry by ID. func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error { log := logging.FromContext(ctx) log.Debug().Int64("id", id).Msg("deleting history entry") release, err := uc.beginHistoryMutation(ctx) if err != nil { return fmt.Errorf("failed to prepare history mutation: %w", err) } release = onceRelease(release) defer release() if err := uc.historyRepo.Delete(ctx, id); err != nil { return fmt.Errorf("failed to delete history entry: %w", err) } release() uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 1, true) log.Debug().Int64("id", id).Msg("history entry deleted") return nil } // DeleteByDomain removes all history entries for a domain. func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error { domain, err := canonicalHistoryDomain(domain) if err != nil { return err } log := logging.FromContext(ctx) log.Debug().Str("domain", domain).Msg("deleting history by domain") release, err := uc.beginHistoryMutation(ctx) if err != nil { return fmt.Errorf("failed to prepare history mutation: %w", err) } release = onceRelease(release) defer release() if err := uc.historyRepo.DeleteByDomain(ctx, domain); err != nil { return fmt.Errorf("failed to delete history by domain: %w", err) } release() uc.publishHistoryChange(ctx, dto.HistoryChangeReasonDelete, 0, false) log.Info().Str("domain", domain).Msg("history deleted for domain") return nil } func onceRelease(release func()) func() { var once sync.Once return func() { once.Do(release) } } func (uc *SearchHistoryUseCase) beginHistoryMutation(ctx context.Context) (func(), error) { uc.mutationCoordinatorMu.RLock() coordinator := uc.mutationCoordinator uc.mutationCoordinatorMu.RUnlock() if coordinator == nil { return func() {}, nil } release, err := coordinator.BeginHistoryMutation(ctx) if err != nil { if release != nil { release() } return nil, err } if release == nil { release = func() {} } return release, nil } func (uc *SearchHistoryUseCase) publishHistoryChange( ctx context.Context, reason dto.HistoryChangeReason, deleteCount int, deleteCountKnown bool, ) { change := dto.HistoryChange{ Reasons: []dto.HistoryChangeReason{reason}, DeleteCount: deleteCount, DeleteCountKnown: deleteCountKnown, ChangedAt: time.Now(), } sink := uc.historyChangeSink() sink.OnHistoryChanged(ctx, change) } func (uc *SearchHistoryUseCase) historyChangeSink() port.HistoryChangeSink { uc.changeSinkMu.RLock() sink := uc.changeSink uc.changeSinkMu.RUnlock() return normalizeHistoryChangeSink(sink) } // GetDomainStats retrieves per-domain visit statistics. func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) { log := logging.FromContext(ctx) log.Debug().Int("limit", limit).Msg("getting domain stats") limit = clampPositiveLimit(limit, defaultDomainStatsLimit, maxDomainStatsLimit) return uc.historyRepo.GetDomainStats(ctx, limit) } // clampOptionalLimit normalizes an optional limit where zero remains a valid // "no limit" value, negatives fall back to defaultLimit, and values above // maxLimit are capped. Use clampPositiveLimit instead when zero should be // treated as invalid input. func clampOptionalLimit(limit, defaultLimit, maxLimit int) int { if limit < 0 { return defaultLimit } if limit > maxLimit { return maxLimit } return limit } // clampPositiveLimit normalizes a required positive limit. Values less than or // equal to zero fall back to defaultLimit, values above maxLimit are capped, // and the result stays within the expected positive range. Use this for // required limits like domain stats, and clampOptionalLimit when zero has its // own meaning. func clampPositiveLimit(limit, defaultLimit, maxLimit int) int { if limit <= 0 { return defaultLimit } if limit > maxLimit { return maxLimit } return limit } // GetStats retrieves lightweight aggregate history statistics. func (uc *SearchHistoryUseCase) GetStats(ctx context.Context) (*entity.HistoryStats, error) { stats, err := uc.historyRepo.GetStats(ctx) if err != nil { return nil, fmt.Errorf("failed to get history stats: %w", err) } return stats, nil } // GetAnalytics retrieves aggregated history analytics for the homepage. func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) { log := logging.FromContext(ctx) log.Debug().Msg("getting history analytics") // Get basic stats stats, err := uc.historyRepo.GetStats(ctx) if err != nil { return nil, fmt.Errorf("failed to get history stats: %w", err) } // Get top domains domains, err := uc.historyRepo.GetDomainStats(ctx, 10) if err != nil { return nil, fmt.Errorf("failed to get domain stats: %w", err) } // Get daily visits for last 30 days dailyVisits, err := uc.historyRepo.GetDailyVisitCount(ctx, "-30 days") if err != nil { return nil, fmt.Errorf("failed to get daily visits: %w", err) } // Get hourly distribution hourlyDist, err := uc.historyRepo.GetHourlyDistribution(ctx) if err != nil { return nil, fmt.Errorf("failed to get hourly distribution: %w", err) } return &entity.HistoryAnalytics{ TotalEntries: stats.TotalEntries, TotalVisits: stats.TotalVisits, UniqueDays: stats.UniqueDays, TopDomains: domains, DailyVisits: dailyVisits, HourlyDistribution: hourlyDist, }, nil }