v0.31.0 bf0756b

history_repo.go RawHistoryFolder

1package sqlite
2
3import (
4	"context"
5	"database/sql"
6	"errors"
7	"fmt"
8	"net/url"
9	"sort"
10	"strings"
11	"sync/atomic"
12	"time"
13	"unicode"
14
15	"github.com/bnema/dumber/internal/domain/entity"
16	"github.com/bnema/dumber/internal/domain/repository"
17	domainurl "github.com/bnema/dumber/internal/domain/url"
18	"github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
19	"github.com/bnema/dumber/internal/logging"
20)
21
22const logURLMaxLen = 60
23
24const (
25	historyURLMatchScore        = 300.0
26	historyTitleMatchScore      = 120.0
27	historyExactHostScore       = 220.0
28	historyTrimmedHostScore     = 215.0
29	historyHostPrefixScore      = 180.0
30	historyURLPrefixScore       = 170.0
31	historyURLContainsScore     = 80.0
32	historyTitlePrefixScore     = 90.0
33	historyTitleContainsScore   = 40.0
34	historyWordHostPrefixScore  = 40.0
35	historyWordURLContainsScore = 15.0
36	historyWordTitlePrefixScore = 20.0
37	historyWordTitleScore       = 8.0
38	historyPathDepthPenalty     = 18
39	historyRootPathBonus        = 20.0
40	historyDomainBoostScore     = 35.0
41	historyVisitCountCap        = 50.0
42	historyRecentVisitScore     = 10.0
43)
44
45var expectedFTSFailureCount atomic.Uint64
46
47type historyRepo struct {
48	db      *sql.DB
49	queries *sqlc.Queries
50}
51
52// NewHistoryRepository creates a new SQLite-backed history repository.
53func NewHistoryRepository(db *sql.DB) repository.HistoryRepository {
54	return &historyRepo{db: db, queries: sqlc.New(db)}
55}
56
57// aboutBlankURL is the special URL for blank pages that should not accumulate visit counts.
58const aboutBlankURL = "about:blank"
59
60func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) error {
61	log := logging.FromContext(ctx)
62	log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry")
63
64	domain := domainurl.CanonicalDomain(entry.URL)
65	err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{
66		Url:        entry.URL,
67		Title:      sql.NullString{String: entry.Title, Valid: entry.Title != ""},
68		FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},
69		Domain:     sql.NullString{String: domain, Valid: domain != ""},
70	})
71	if err != nil {
72		return err
73	}
74
75	// Cap about:blank visit count to 1 so it exists but never dominates suggestions
76	if entry.URL == aboutBlankURL {
77		capErr := r.queries.CapVisitCount(ctx, sqlc.CapVisitCountParams{
78			VisitCount:   sql.NullInt64{Int64: 1, Valid: true},
79			Url:          aboutBlankURL,
80			VisitCount_2: sql.NullInt64{Int64: 1, Valid: true},
81		})
82		if capErr != nil {
83			log.Debug().Err(capErr).Msg("failed to cap about:blank visit count")
84		}
85	}
86
87	return nil
88}
89
90func (r *historyRepo) FindByURL(ctx context.Context, rawURL string) (*entity.HistoryEntry, error) {
91	row, err := r.queries.GetHistoryByURL(ctx, rawURL)
92	if err != nil {
93		if errors.Is(err, sql.ErrNoRows) {
94			return nil, nil
95		}
96		return nil, err
97	}
98	return historyFromRow(row), nil
99}
100
101func (r *historyRepo) UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error {
102	if entry == nil || entry.URL == "" {
103		return fmt.Errorf("history entry URL cannot be empty")
104	}
105	_, err := r.db.ExecContext(
106		ctx,
107		`UPDATE history
108		 SET title = COALESCE(?, title),
109		     favicon_url = COALESCE(?, favicon_url)
110		 WHERE url = ?`,
111		nullString(entry.Title),
112		nullString(entry.FaviconURL),
113		entry.URL,
114	)
115	return err
116}
117
118func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
119	if limit <= 0 {
120		return []entity.HistoryMatch{}, nil
121	}
122
123	words := sanitizeFTS5QueryTokens(query)
124	if len(words) == 0 {
125		return []entity.HistoryMatch{}, nil
126	}
127
128	// Build FTS5 query: "word1* word2*" (implicit AND between terms)
129	parts := make([]string, len(words))
130	for i, word := range words {
131		parts[i] = word + "*"
132	}
133	ftsQuery := strings.Join(parts, " ")
134
135	// Search both URL and title columns, then merge results
136	// Use domain-boosted query for URL search to prioritize domain matches
137	// Use sanitized first word for domain boost (intentional: domain boost targets first search term)
138	urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{
139		Term:  sql.NullString{String: words[0], Valid: true},
140		Query: ftsQuery,
141		Limit: int64(limit),
142	})
143	if urlErr != nil {
144		logFTSSearchError(ctx, "url", query, urlErr)
145		urlRows = []sqlc.SearchHistoryFTSUrlWithDomainBoostRow{}
146	}
147
148	titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{
149		Query: ftsQuery,
150		Limit: int64(limit),
151	})
152	if titleErr != nil {
153		logFTSSearchError(ctx, "title", query, titleErr)
154		titleRows = []sqlc.History{}
155	}
156
157	ranked := rankHistoryMatches(query, words, urlRows, titleRows)
158	if limit < len(ranked) {
159		ranked = ranked[:limit]
160	}
161	return ranked, nil
162}
163
164func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *entity.HistoryEntry {
165	return &entity.HistoryEntry{
166		ID:          row.ID,
167		URL:         row.Url,
168		Title:       row.Title.String,
169		FaviconURL:  row.FaviconUrl.String,
170		VisitCount:  row.VisitCount.Int64,
171		LastVisited: row.LastVisited.Time,
172		CreatedAt:   row.CreatedAt.Time,
173	}
174}
175
176func nullString(value string) sql.NullString {
177	return sql.NullString{String: value, Valid: value != ""}
178}
179
180type historySearchCandidate struct {
181	entry         *entity.HistoryEntry
182	score         float64
183	urlMatch      bool
184	titleMatch    bool
185	domainBoost   int64
186	exactURLMatch bool
187	prefixURL     bool
188	prefixTitle   bool
189}
190
191func rankHistoryMatches(
192	query string,
193	words []string,
194	urlRows []sqlc.SearchHistoryFTSUrlWithDomainBoostRow,
195	titleRows []sqlc.History,
196) []entity.HistoryMatch {
197	candidates := make(map[int64]*historySearchCandidate, len(urlRows)+len(titleRows))
198
199	for i := range urlRows {
200		row := urlRows[i]
201		entry := historyFromDomainBoostRow(row)
202		candidate := ensureHistoryCandidate(candidates, entry)
203		candidate.urlMatch = true
204		candidate.domainBoost = maxInt64(candidate.domainBoost, row.DomainBoost)
205	}
206
207	for i := range titleRows {
208		row := titleRows[i]
209		entry := historyFromRow(row)
210		candidate := ensureHistoryCandidate(candidates, entry)
211		candidate.titleMatch = true
212	}
213
214	queryLower := strings.ToLower(strings.TrimSpace(query))
215	ranked := make([]rankedHistoryMatch, 0, len(candidates))
216	for _, candidate := range candidates {
217		candidate.score = computeHistoryCandidateScore(candidate, queryLower, words)
218		ranked = append(ranked, rankedHistoryMatch{
219			match: entity.HistoryMatch{
220				Entry: candidate.entry,
221				Score: candidate.score,
222			},
223			score:         candidate.score,
224			exactURLMatch: candidate.exactURLMatch,
225			prefixURL:     candidate.prefixURL,
226			urlMatch:      candidate.urlMatch,
227			prefixTitle:   candidate.prefixTitle,
228		})
229	}
230
231	sortRankedHistoryMatches(ranked)
232
233	matches := make([]entity.HistoryMatch, len(ranked))
234	for i := range ranked {
235		matches[i] = ranked[i].match
236	}
237	return matches
238}
239
240type rankedHistoryMatch struct {
241	match         entity.HistoryMatch
242	score         float64
243	exactURLMatch bool
244	prefixURL     bool
245	urlMatch      bool
246	prefixTitle   bool
247}
248
249func sortRankedHistoryMatches(items []rankedHistoryMatch) {
250	sort.Slice(items, func(i, j int) bool {
251		li, ri := &items[i], &items[j]
252		if li.score != ri.score {
253			return li.score > ri.score
254		}
255		if li.exactURLMatch != ri.exactURLMatch {
256			return li.exactURLMatch
257		}
258		if li.prefixURL != ri.prefixURL {
259			return li.prefixURL
260		}
261		if li.urlMatch != ri.urlMatch {
262			return li.urlMatch
263		}
264		if li.prefixTitle != ri.prefixTitle {
265			return li.prefixTitle
266		}
267		if li.match.Entry.VisitCount != ri.match.Entry.VisitCount {
268			return li.match.Entry.VisitCount > ri.match.Entry.VisitCount
269		}
270		if !li.match.Entry.LastVisited.Equal(ri.match.Entry.LastVisited) {
271			return li.match.Entry.LastVisited.After(ri.match.Entry.LastVisited)
272		}
273		return li.match.Entry.URL < ri.match.Entry.URL
274	})
275}
276
277func ensureHistoryCandidate(candidates map[int64]*historySearchCandidate, entry *entity.HistoryEntry) *historySearchCandidate {
278	candidate, ok := candidates[entry.ID]
279	if ok {
280		return candidate
281	}
282	candidate = &historySearchCandidate{entry: entry}
283	candidates[entry.ID] = candidate
284	return candidate
285}
286
287func computeHistoryCandidateScore(candidate *historySearchCandidate, queryLower string, words []string) float64 {
288	if candidate == nil || candidate.entry == nil {
289		return 0
290	}
291
292	urlLower := strings.ToLower(candidate.entry.URL)
293	titleLower := strings.ToLower(candidate.entry.Title)
294	hostLower := strings.ToLower(domainurl.ExtractDomain(candidate.entry.URL))
295
296	score := 0.0
297	if candidate.urlMatch {
298		score += historyURLMatchScore
299	}
300	if candidate.titleMatch {
301		score += historyTitleMatchScore
302	}
303
304	score += scoreQueryMatch(candidate, queryLower, hostLower, urlLower, titleLower)
305	score += scoreHistoryWords(words, hostLower, urlLower, titleLower)
306
307	if looksLikeHostStyleQuery(queryLower) {
308		pathDepth := historyPathDepth(candidate.entry.URL)
309		score -= float64(pathDepth * historyPathDepthPenalty)
310		if pathDepth == 0 {
311			score += historyRootPathBonus
312		}
313	}
314
315	score += float64(candidate.domainBoost) * historyDomainBoostScore
316	score += minFloat64(float64(candidate.entry.VisitCount), historyVisitCountCap)
317	if time.Since(candidate.entry.LastVisited) <= 7*24*time.Hour {
318		score += historyRecentVisitScore
319	}
320	return score
321}
322
323func scoreQueryMatch(
324	candidate *historySearchCandidate,
325	queryLower, hostLower, urlLower, titleLower string,
326) float64 {
327	if queryLower == "" {
328		return 0
329	}
330
331	switch {
332	case hostLower == queryLower:
333		candidate.exactURLMatch = true
334		return historyExactHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
335	case strings.TrimPrefix(hostLower, "www.") == queryLower:
336		candidate.exactURLMatch = true
337		return historyTrimmedHostScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
338	case strings.HasPrefix(hostLower, queryLower):
339		candidate.prefixURL = true
340		return historyHostPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
341	case strings.HasPrefix(urlLower, queryLower):
342		candidate.prefixURL = true
343		return historyURLPrefixScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
344	case strings.Contains(urlLower, queryLower):
345		return historyURLContainsScore + scoreTitleQueryMatch(candidate, queryLower, titleLower)
346	default:
347		return scoreTitleQueryMatch(candidate, queryLower, titleLower)
348	}
349}
350
351func scoreTitleQueryMatch(candidate *historySearchCandidate, queryLower, titleLower string) float64 {
352	switch {
353	case strings.HasPrefix(titleLower, queryLower):
354		candidate.prefixTitle = true
355		return historyTitlePrefixScore
356	case strings.Contains(titleLower, queryLower):
357		return historyTitleContainsScore
358	default:
359		return 0
360	}
361}
362
363func scoreHistoryWords(words []string, hostLower, urlLower, titleLower string) float64 {
364	score := 0.0
365	for _, word := range words {
366		wordLower := strings.ToLower(word)
367		if wordLower == "" {
368			continue
369		}
370		if strings.HasPrefix(hostLower, wordLower) {
371			score += historyWordHostPrefixScore
372		} else if strings.Contains(urlLower, wordLower) {
373			score += historyWordURLContainsScore
374		}
375		if strings.HasPrefix(titleLower, wordLower) {
376			score += historyWordTitlePrefixScore
377		} else if strings.Contains(titleLower, wordLower) {
378			score += historyWordTitleScore
379		}
380	}
381	return score
382}
383
384func looksLikeHostStyleQuery(query string) bool {
385	return query != "" && !strings.ContainsAny(query, " /?#=&")
386}
387
388func historyPathDepth(rawURL string) int {
389	parsed, err := url.Parse(rawURL)
390	if err != nil {
391		return 0
392	}
393	trimmed := strings.Trim(parsed.Path, "/")
394	if trimmed == "" {
395		return 0
396	}
397	return len(strings.Split(trimmed, "/"))
398}
399
400func maxInt64(left, right int64) int64 {
401	if left > right {
402		return left
403	}
404	return right
405}
406
407func minFloat64(left, right float64) float64 {
408	if left < right {
409		return left
410	}
411	return right
412}
413
414// sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens.
415func sanitizeFTS5QueryTokens(query string) []string {
416	normalized := normalizeFTSSeparators(query)
417	rawTokens := strings.Fields(normalized)
418	if len(rawTokens) == 0 {
419		return nil
420	}
421
422	tokens := make([]string, 0, len(rawTokens))
423	for _, token := range rawTokens {
424		sanitized := sanitizeFTS5Word(token)
425		if sanitized == "" {
426			continue
427		}
428		upper := strings.ToUpper(sanitized)
429		if upper == "AND" || upper == "OR" || upper == "NOT" || upper == "NEAR" {
430			continue
431		}
432		tokens = append(tokens, sanitized)
433	}
434	return tokens
435}
436
437// normalizeFTSSeparators replaces non-alphanumeric separators with spaces.
438func normalizeFTSSeparators(s string) string {
439	var b strings.Builder
440	b.Grow(len(s))
441	for _, r := range s {
442		if unicode.IsLetter(r) || unicode.IsNumber(r) {
443			b.WriteRune(r)
444		} else {
445			b.WriteByte(' ')
446		}
447	}
448	return b.String()
449}
450
451// sanitizeFTS5Word keeps only alphanumeric runes in a single token.
452func sanitizeFTS5Word(word string) string {
453	var result strings.Builder
454	result.Grow(len(word))
455	for _, r := range word {
456		if unicode.IsLetter(r) || unicode.IsNumber(r) {
457			result.WriteRune(r)
458		}
459	}
460	return result.String()
461}
462
463func logFTSSearchError(ctx context.Context, part, query string, err error) {
464	log := logging.FromContext(ctx)
465	if isExpectedFTSError(err) {
466		count := expectedFTSFailureCount.Add(1)
467		if count == 1 || count%100 == 0 {
468			log.Debug().
469				Uint64("expected_error_count", count).
470				Str("part", part).
471				Msg("expected FTS errors observed")
472		}
473		return
474	}
475	log.Debug().Err(err).Str("part", part).Str("query", query).Msg("FTS search failed")
476}
477
478func isExpectedFTSError(err error) bool {
479	msg := strings.ToLower(err.Error())
480	return strings.Contains(msg, "malformed match") ||
481		strings.Contains(msg, "fts5: syntax error") ||
482		strings.Contains(msg, "unterminated string")
483}
484
485func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
486	if limit <= 0 {
487		rows, err := r.queries.GetAllRecentHistory(ctx)
488		if err != nil {
489			return nil, err
490		}
491		return historyEntriesFromRows(rows), nil
492	}
493	rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
494		Limit:  int64(limit),
495		Offset: int64(offset),
496	})
497	if err != nil {
498		return nil, err
499	}
500	return historyEntriesFromRows(rows), nil
501}
502
503func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
504	rawDomain := domain
505	domain = domainurl.CanonicalDomain(domain)
506	if domain == "" {
507		return nil, fmt.Errorf("history domain is required: %q", rawDomain)
508	}
509	if limit <= 0 {
510		rows, err := r.queries.GetAllRecentHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true})
511		if err != nil {
512			return nil, err
513		}
514		return historyEntriesFromRows(rows), nil
515	}
516	rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{
517		Domain: sql.NullString{String: domain, Valid: true},
518		Limit:  int64(limit),
519		Offset: int64(offset),
520	})
521	if err != nil {
522		return nil, err
523	}
524	return historyEntriesFromRows(rows), nil
525}
526
527func (r *historyRepo) GetRecentWindow(ctx context.Context, before time.Time, beforeID int64, limit int) ([]*entity.HistoryEntry, error) {
528	if limit <= 0 {
529		return []*entity.HistoryEntry{}, nil
530	}
531	rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{
532		Before:   sql.NullTime{Time: before, Valid: true},
533		BeforeID: beforeID,
534		Limit:    int64(limit),
535	})
536	if err != nil {
537		return nil, err
538	}
539	return historyEntriesFromRows(rows), nil
540}
541
542func (r *historyRepo) GetRecentWindowByDomain(
543	ctx context.Context,
544	domain string,
545	before time.Time,
546	beforeID int64,
547	limit int,
548) ([]*entity.HistoryEntry, error) {
549	rawDomain := domain
550	domain = domainurl.CanonicalDomain(domain)
551	if domain == "" {
552		return nil, fmt.Errorf("history domain is required: %q", rawDomain)
553	}
554	if limit <= 0 {
555		return []*entity.HistoryEntry{}, nil
556	}
557	rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{
558		Domain:   sql.NullString{String: domain, Valid: true},
559		Before:   sql.NullTime{Time: before, Valid: true},
560		BeforeID: beforeID,
561		Limit:    int64(limit),
562	})
563	if err != nil {
564		return nil, err
565	}
566	return historyEntriesFromRows(rows), nil
567}
568
569func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
570	if days <= 0 {
571		return nil, fmt.Errorf("days must be positive, got %d", days)
572	}
573	// Format: "-N days" for SQLite datetime modifier
574	daysModifier := fmt.Sprintf("-%d days", days)
575	rows, err := r.queries.GetRecentHistorySince(ctx, daysModifier)
576	if err != nil {
577		return nil, err
578	}
579
580	return historyEntriesFromRows(rows), nil
581}
582
583func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
584	if days <= 0 {
585		return nil, fmt.Errorf("days must be positive, got %d", days)
586	}
587	// Format: "-N days" for SQLite datetime modifier
588	daysModifier := fmt.Sprintf("-%d days", days)
589	rows, err := r.queries.GetMostVisited(ctx, daysModifier)
590	if err != nil {
591		return nil, err
592	}
593
594	return historyEntriesFromRows(rows), nil
595}
596
597func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
598	rows, err := r.queries.GetAllRecentHistory(ctx)
599	if err != nil {
600		return nil, err
601	}
602
603	return historyEntriesFromRows(rows), nil
604}
605
606func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
607	rows, err := r.queries.GetAllMostVisited(ctx)
608	if err != nil {
609		return nil, err
610	}
611
612	return historyEntriesFromRows(rows), nil
613}
614
615func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {
616	// Skip incrementing about:blank - it should always have visit_count = 1
617	if rawURL == aboutBlankURL {
618		return nil
619	}
620	return r.queries.IncrementVisitCount(ctx, rawURL)
621}
622
623func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, rawURL string, delta int) error {
624	// Skip incrementing about:blank - it should always have visit_count = 1
625	if rawURL == aboutBlankURL {
626		return nil
627	}
628	if delta <= 0 {
629		return fmt.Errorf("visit delta must be > 0, got %d", delta)
630	}
631	return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{
632		VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true},
633		Url:        rawURL,
634	})
635}
636
637func (r *historyRepo) Delete(ctx context.Context, id int64) error {
638	return r.queries.DeleteHistoryByID(ctx, id)
639}
640
641func (r *historyRepo) DeleteOlderThan(ctx context.Context, before time.Time) error {
642	return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true})
643}
644
645func (r *historyRepo) DeleteSince(ctx context.Context, since time.Time) error {
646	return r.queries.DeleteHistorySince(ctx, sql.NullTime{Time: since, Valid: true})
647}
648
649func (r *historyRepo) DeleteAll(ctx context.Context) error {
650	return r.queries.DeleteAllHistory(ctx)
651}
652
653func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {
654	rawDomain := domain
655	domain = domainurl.CanonicalDomain(domain)
656	if domain == "" {
657		return fmt.Errorf("history domain is required: %q", rawDomain)
658	}
659	return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true})
660}
661
662func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
663	row, err := r.queries.GetHistoryStats(ctx)
664	if err != nil {
665		return nil, err
666	}
667
668	// Handle the interface{} type for total_visits
669	var totalVisits int64
670	switch v := row.TotalVisits.(type) {
671	case int64:
672		totalVisits = v
673	case float64:
674		totalVisits = int64(v)
675	}
676
677	return &entity.HistoryStats{
678		TotalEntries: row.TotalEntries,
679		TotalVisits:  totalVisits,
680		UniqueDays:   row.UniqueDays,
681	}, nil
682}
683
684func (r *historyRepo) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
685	rows, err := r.queries.GetDomainStats(ctx, int64(limit))
686	if err != nil {
687		return nil, err
688	}
689
690	stats := make([]*entity.DomainStat, len(rows))
691	for i, row := range rows {
692		var totalVisits int64
693		if row.TotalVisits.Valid {
694			totalVisits = int64(row.TotalVisits.Float64)
695		}
696
697		var lastVisit time.Time
698		if v, ok := row.LastVisit.(string); ok && v != "" {
699			lastVisit, _ = time.Parse("2006-01-02 15:04:05", v)
700		}
701
702		stats[i] = &entity.DomainStat{
703			Domain:      row.Domain,
704			PageCount:   row.PageCount,
705			TotalVisits: totalVisits,
706			LastVisit:   lastVisit,
707		}
708	}
709	return stats, nil
710}
711
712func (r *historyRepo) GetHourlyDistribution(ctx context.Context) ([]*entity.HourlyDistribution, error) {
713	rows, err := r.queries.GetHourlyDistribution(ctx)
714	if err != nil {
715		return nil, err
716	}
717
718	dist := make([]*entity.HourlyDistribution, len(rows))
719	for i, row := range rows {
720		dist[i] = &entity.HourlyDistribution{
721			Hour:       int(row.Hour),
722			VisitCount: row.VisitCount,
723		}
724	}
725	return dist, nil
726}
727
728func (r *historyRepo) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
729	rows, err := r.queries.GetDailyVisitCount(ctx, daysAgo)
730	if err != nil {
731		return nil, err
732	}
733
734	counts := make([]*entity.DailyVisitCount, len(rows))
735	for i, row := range rows {
736		var day string
737		if v, ok := row.Day.(string); ok {
738			day = v
739		}
740
741		var visits int64
742		if row.Visits.Valid {
743			visits = int64(row.Visits.Float64)
744		}
745
746		counts[i] = &entity.DailyVisitCount{
747			Day:     day,
748			Entries: row.Entries,
749			Visits:  visits,
750		}
751	}
752	return counts, nil
753}
754
755func historyEntriesFromRows(rows []sqlc.History) []*entity.HistoryEntry {
756	entries := make([]*entity.HistoryEntry, len(rows))
757	for i := range rows {
758		entries[i] = historyFromRow(rows[i])
759	}
760	return entries
761}
762
763func historyFromRow(row sqlc.History) *entity.HistoryEntry {
764	return &entity.HistoryEntry{
765		ID:          row.ID,
766		URL:         row.Url,
767		Title:       row.Title.String,
768		FaviconURL:  row.FaviconUrl.String,
769		VisitCount:  row.VisitCount.Int64,
770		LastVisited: row.LastVisited.Time,
771		CreatedAt:   row.CreatedAt.Time,
772	}
773}

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