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}