main 5041353

search_history.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close