v0.31.0 bf0756b

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
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}

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