v0.29.0 21b3150

search_history.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"time"
8
9	"github.com/bnema/dumber/internal/application/dto"
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/domain/entity"
12	historydomain "github.com/bnema/dumber/internal/domain/history"
13	"github.com/bnema/dumber/internal/domain/repository"
14	domainurl "github.com/bnema/dumber/internal/domain/url"
15	"github.com/bnema/dumber/internal/logging"
16)
17
18// Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory.
19var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)
20
21const historyWindowDuration = 24 * time.Hour
22
23// SearchHistoryUseCase handles history search and retrieval operations.
24type SearchHistoryUseCase struct {
25	historyRepo repository.HistoryRepository
26}
27
28// NewSearchHistoryUseCase creates a new history search use case.
29func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHistoryUseCase {
30	return &SearchHistoryUseCase{
31		historyRepo: historyRepo,
32	}
33}
34
35// SearchInput is an alias for dto.HistorySearchInput.
36type SearchInput = dto.HistorySearchInput
37
38// SearchOutput is an alias for dto.HistorySearchOutput.
39type SearchOutput = dto.HistorySearchOutput
40
41// Search performs a full-text search on history entries using SQLite FTS5.
42// Returns only entries that actually match the query terms.
43func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) {
44	log := logging.FromContext(ctx)
45
46	if input.Query == "" {
47		return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil
48	}
49
50	limit := input.Limit
51	if limit <= 0 {
52		limit = 20 // Default limit
53	}
54
55	// Use repository's FTS5 search
56	matches, err := uc.historyRepo.Search(ctx, input.Query, limit)
57	if err != nil {
58		return nil, fmt.Errorf("failed to search history: %w", err)
59	}
60
61	log.Debug().
62		Str("query", input.Query).
63		Int("matches", len(matches)).
64		Msg("FTS5 search completed")
65
66	return &SearchOutput{Matches: matches}, nil
67}
68
69// GetRecent retrieves recent history entries. A zero limit means all entries;
70// negative limits retain the historical default page size.
71func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
72	if limit < 0 {
73		limit = 50 // Default limit for invalid negative values.
74	}
75
76	entries, err := uc.historyRepo.GetRecent(ctx, limit, offset)
77	if err != nil {
78		return nil, fmt.Errorf("failed to get recent history: %w", err)
79	}
80
81	return entries, nil
82}
83
84// GetRecentByDomain retrieves recent history entries for a canonical domain. A
85// zero limit means all matching entries; negative limits use the default page size.
86func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
87	if limit < 0 {
88		limit = 50
89	}
90	domain, err := canonicalHistoryDomain(domain)
91	if err != nil {
92		return nil, err
93	}
94
95	entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset)
96	if err != nil {
97		return nil, fmt.Errorf("failed to get recent history by domain: %w", err)
98	}
99	return entries, nil
100}
101
102// canonicalHistoryDomain normalizes a domain and rejects empty values before
103// the domain reaches exact-match persistence queries.
104func canonicalHistoryDomain(domain string) (string, error) {
105	domain = domainurl.CanonicalDomain(domain)
106	if domain == "" {
107		return "", fmt.Errorf("domain is required")
108	}
109	return domain, nil
110}
111
112// GetRecentWindow retrieves the 24-hour history window ending before the given cursor.
113func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
114	if before.IsZero() {
115		before = time.Now()
116	}
117	before = before.UTC()
118	after := before.Add(-historyWindowDuration)
119
120	var (
121		entries []*entity.HistoryEntry
122		hasMore bool
123		err     error
124	)
125	originalDomain := domain
126	domain = domainurl.CanonicalDomain(domain)
127	if strings.TrimSpace(originalDomain) != "" && domain == "" {
128		return nil, fmt.Errorf("domain is required")
129	}
130	if domain != "" {
131		entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after)
132		if err == nil {
133			hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after)
134		}
135	} else {
136		entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after)
137		if err == nil {
138			hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after)
139		}
140	}
141	if err != nil {
142		return nil, fmt.Errorf("failed to get recent history window: %w", err)
143	}
144
145	return &entity.HistoryWindow{
146		Entries: entries,
147		Before:  before,
148		After:   after,
149		HasMore: hasMore,
150	}, nil
151}
152
153// GetRecentSince retrieves history entries visited within the last N days.
154// If days is 0, returns all history entries.
155// If days is negative, defaults to 30 days.
156func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
157	if days < 0 {
158		days = 30 // Default to 30 days for negative values
159	}
160
161	var entries []*entity.HistoryEntry
162	var err error
163
164	switch days {
165	case 0:
166		entries, err = uc.historyRepo.GetAllRecentHistory(ctx)
167	default:
168		entries, err = uc.historyRepo.GetRecentSince(ctx, days)
169	}
170
171	if err != nil {
172		return nil, fmt.Errorf("failed to get recent history: %w", err)
173	}
174
175	return entries, nil
176}
177
178// GetMostVisited retrieves history entries sorted by visit count within the last N days.
179// If days is 0, returns all history entries sorted by visit count.
180// If days is negative, defaults to 30 days.
181func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
182	if days < 0 {
183		days = 30 // Default to 30 days for negative values
184	}
185
186	var entries []*entity.HistoryEntry
187	var err error
188
189	switch days {
190	case 0:
191		entries, err = uc.historyRepo.GetAllMostVisited(ctx)
192	default:
193		entries, err = uc.historyRepo.GetMostVisited(ctx, days)
194	}
195
196	if err != nil {
197		return nil, fmt.Errorf("failed to get most visited history: %w", err)
198	}
199
200	return entries, nil
201}
202
203// FindByURL retrieves a history entry by its URL.
204func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
205	log := logging.FromContext(ctx)
206	log.Debug().Str("url", url).Msg("finding history entry by URL")
207
208	entry, err := uc.historyRepo.FindByURL(ctx, url)
209	if err != nil {
210		return nil, fmt.Errorf("failed to find history entry: %w", err)
211	}
212
213	if entry == nil {
214		log.Debug().Str("url", url).Msg("history entry not found")
215	}
216
217	return entry, nil
218}
219
220// ClearOlderThan deletes history entries older than the specified time.
221func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.Time) error {
222	log := logging.FromContext(ctx)
223	log.Debug().Time("before", before).Msg("clearing old history")
224
225	if err := uc.historyRepo.DeleteOlderThan(ctx, before); err != nil {
226		return fmt.Errorf("failed to clear history: %w", err)
227	}
228
229	log.Info().Time("before", before).Msg("old history cleared")
230	return nil
231}
232
233// ClearRange deletes history entries inside a named recent range.
234func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
235	cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
236	if !ok {
237		return fmt.Errorf("unknown history delete range: %q", rangeID)
238	}
239	if all {
240		return uc.ClearAll(ctx)
241	}
242
243	log := logging.FromContext(ctx)
244	log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range")
245	if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil {
246		return fmt.Errorf("failed to clear history range: %w", err)
247	}
248	log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared")
249	return nil
250}
251
252// ClearAll deletes all history entries.
253func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
254	log := logging.FromContext(ctx)
255	log.Debug().Msg("clearing all history")
256
257	if err := uc.historyRepo.DeleteAll(ctx); err != nil {
258		return fmt.Errorf("failed to clear all history: %w", err)
259	}
260
261	log.Info().Msg("all history cleared")
262	return nil
263}
264
265// Delete removes a single history entry by ID.
266func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
267	log := logging.FromContext(ctx)
268	log.Debug().Int64("id", id).Msg("deleting history entry")
269
270	if err := uc.historyRepo.Delete(ctx, id); err != nil {
271		return fmt.Errorf("failed to delete history entry: %w", err)
272	}
273
274	log.Debug().Int64("id", id).Msg("history entry deleted")
275	return nil
276}
277
278// DeleteByDomain removes all history entries for a domain.
279func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {
280	domain, err := canonicalHistoryDomain(domain)
281	if err != nil {
282		return err
283	}
284
285	log := logging.FromContext(ctx)
286	log.Debug().Str("domain", domain).Msg("deleting history by domain")
287
288	if err := uc.historyRepo.DeleteByDomain(ctx, domain); err != nil {
289		return fmt.Errorf("failed to delete history by domain: %w", err)
290	}
291
292	log.Info().Str("domain", domain).Msg("history deleted for domain")
293	return nil
294}
295
296// GetDomainStats retrieves per-domain visit statistics.
297func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
298	log := logging.FromContext(ctx)
299	log.Debug().Int("limit", limit).Msg("getting domain stats")
300
301	if limit <= 0 {
302		limit = 20
303	}
304
305	return uc.historyRepo.GetDomainStats(ctx, limit)
306}
307
308// GetStats retrieves lightweight aggregate history statistics.
309func (uc *SearchHistoryUseCase) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
310	stats, err := uc.historyRepo.GetStats(ctx)
311	if err != nil {
312		return nil, fmt.Errorf("failed to get history stats: %w", err)
313	}
314	return stats, nil
315}
316
317// GetAnalytics retrieves aggregated history analytics for the homepage.
318func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
319	log := logging.FromContext(ctx)
320	log.Debug().Msg("getting history analytics")
321
322	// Get basic stats
323	stats, err := uc.historyRepo.GetStats(ctx)
324	if err != nil {
325		return nil, fmt.Errorf("failed to get history stats: %w", err)
326	}
327
328	// Get top domains
329	domains, err := uc.historyRepo.GetDomainStats(ctx, 10)
330	if err != nil {
331		return nil, fmt.Errorf("failed to get domain stats: %w", err)
332	}
333
334	// Get daily visits for last 30 days
335	dailyVisits, err := uc.historyRepo.GetDailyVisitCount(ctx, "-30 days")
336	if err != nil {
337		return nil, fmt.Errorf("failed to get daily visits: %w", err)
338	}
339
340	// Get hourly distribution
341	hourlyDist, err := uc.historyRepo.GetHourlyDistribution(ctx)
342	if err != nil {
343		return nil, fmt.Errorf("failed to get hourly distribution: %w", err)
344	}
345
346	return &entity.HistoryAnalytics{
347		TotalEntries:       stats.TotalEntries,
348		TotalVisits:        stats.TotalVisits,
349		UniqueDays:         stats.UniqueDays,
350		TopDomains:         domains,
351		DailyVisits:        dailyVisits,
352		HourlyDistribution: hourlyDist,
353	}, nil
354}

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