1package usecase
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "strings"
8 "sync"
9 "time"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/domain/repository"
14 "github.com/bnema/dumber/internal/logging"
15)
16
17const (
18 // historyQueueSize is the buffer size for the async history queue.
19 // If the queue is full, new records are dropped with a warning.
20 historyQueueSize = 100
21
22 // logURLMaxLen is the max length for URLs in log messages.
23 logURLMaxLen = 60
24
25 // historyWorkerFlushInterval coalesces bursts into fewer persistence writes.
26 historyWorkerFlushInterval = 100 * time.Millisecond
27
28 // historyFallbackIncrementCap bounds DB writes when delta-increment API is unavailable.
29 historyFallbackIncrementCap = 256
30)
31
32// historyRecord holds data for async history recording.
33type historyRecord struct {
34 url string
35 visits int
36}
37
38type historyVisitDeltaIncrementer interface {
39 IncrementVisitCountBy(ctx context.Context, url string, delta int) error
40}
41
42type historyMetadataUpdater interface {
43 UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error
44}
45
46type paneHistoryState struct {
47 lastRawURL string
48 lastCanonicalURL string
49 lastRecordedAt time.Time
50}
51
52type historyCanonicalizationOptions struct {
53 StripTrackingParams bool
54}
55
56// NavigateUseCase handles URL navigation with history recording and zoom application.
57type NavigateUseCase struct {
58 historyRepo repository.HistoryRepository
59 zoomRepo repository.ZoomRepository
60 defaultZoom float64
61 recentMu sync.Mutex
62 recentVisits map[string]paneHistoryState // key: pane ID, value: per-pane history state
63 closeOnce sync.Once
64
65 // Async history recording
66 historyQueue chan historyRecord
67 done chan struct{}
68 wg sync.WaitGroup
69 ctx context.Context // Base context for background worker
70}
71
72// NewNavigateUseCase creates a new navigation use case.
73// defaultZoom is the zoom level to use when no per-domain zoom is saved (typically from config).
74func NewNavigateUseCase(
75 historyRepo repository.HistoryRepository,
76 zoomRepo repository.ZoomRepository,
77 defaultZoom float64,
78) *NavigateUseCase {
79 if defaultZoom <= 0 {
80 defaultZoom = entity.ZoomDefault
81 }
82
83 uc := &NavigateUseCase{
84 historyRepo: historyRepo,
85 zoomRepo: zoomRepo,
86 defaultZoom: defaultZoom,
87 recentVisits: make(map[string]paneHistoryState),
88 historyQueue: make(chan historyRecord, historyQueueSize),
89 done: make(chan struct{}),
90 ctx: context.Background(),
91 }
92
93 // Start background history worker
94 uc.wg.Add(1)
95 go uc.historyWorker()
96
97 return uc
98}
99
100// Close shuts down the background history worker and drains any pending records.
101// This should be called when the application is shutting down.
102func (uc *NavigateUseCase) Close() {
103 uc.closeOnce.Do(func() {
104 close(uc.done)
105 })
106 uc.wg.Wait()
107}
108
109// NavigateInput contains parameters for navigation.
110type NavigateInput struct {
111 URL string
112 PaneID string
113 WebView port.WebView
114}
115
116// NavigateOutput contains the result of navigation.
117type NavigateOutput struct {
118 AppliedZoom float64
119}
120
121// Execute navigates to a URL and records history.
122// Zoom is applied later via LoadCommitted event in ContentCoordinator.
123func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
124 log := logging.FromContext(ctx)
125 log.Debug().
126 Str("url", input.URL).
127 Str("pane_id", input.PaneID).
128 Msg("navigating to URL")
129
130 // Navigate to URL (zoom will be applied on LoadCommitted to avoid shift)
131 // History is recorded on LoadCommitted when URI is guaranteed correct
132 if err := input.WebView.LoadURI(ctx, input.URL); err != nil {
133 return nil, fmt.Errorf("failed to load URL: %w", err)
134 }
135
136 log.Info().
137 Str("url", input.URL).
138 Msg("navigation initiated")
139
140 return &NavigateOutput{
141 AppliedZoom: uc.defaultZoom,
142 }, nil
143}
144
145// historyDeduplicationWindow is the time window for deduplicating history visits.
146// Visits to the same URL within this window count as a single visit.
147// This prevents inflation from redirects and rapid navigation.
148const historyDeduplicationWindow = 2 * time.Second
149
150// stripTrackingParamsForHistoryDedup controls whether known tracking parameters
151// are removed while canonicalizing URLs for history deduplication/storage.
152const stripTrackingParamsForHistoryDedup = true
153
154// RecordHistory queues a history entry for async recording.
155// This is non-blocking to avoid SQLite I/O on the GTK main thread.
156// Should be called on LoadCommitted when URI is guaranteed correct.
157func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL string) {
158 log := logging.FromContext(ctx)
159 rawURL = strings.TrimSpace(rawURL)
160 if rawURL == "" {
161 return
162 }
163
164 canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
165 StripTrackingParams: stripTrackingParamsForHistoryDedup,
166 })
167 if canonicalURL == "" {
168 return
169 }
170
171 paneID = normalizePaneID(paneID)
172
173 now := time.Now()
174
175 uc.recentMu.Lock()
176 state := uc.recentVisits[paneID]
177
178 // Ignore in-page hash transitions for history persistence.
179 if isHashOnlyTransition(state.lastRawURL, rawURL) {
180 state.lastRawURL = rawURL
181 uc.recentVisits[paneID] = state
182 uc.recentMu.Unlock()
183 return
184 }
185
186 // Per-pane short-window deduplication by canonical URL.
187 if state.lastCanonicalURL == canonicalURL && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
188 state.lastRawURL = rawURL
189 uc.recentVisits[paneID] = state
190 uc.recentMu.Unlock()
191 return
192 }
193
194 state.lastRawURL = rawURL
195 state.lastCanonicalURL = canonicalURL
196 state.lastRecordedAt = now
197 uc.recentVisits[paneID] = state
198 uc.recentMu.Unlock()
199
200 // Non-blocking send to async queue
201 select {
202 case uc.historyQueue <- historyRecord{url: canonicalURL, visits: 1}:
203 default:
204 // Queue full - log warning but don't block navigation
205 log.Warn().Str("url", logging.TruncateURL(canonicalURL, logURLMaxLen)).Msg("history queue full, dropping record")
206 }
207}
208
209// ClearPaneHistory removes per-pane deduplication state to prevent unbounded growth.
210// Callers should invoke this when a pane is closed.
211func (uc *NavigateUseCase) ClearPaneHistory(paneID string) {
212 paneID = normalizePaneID(paneID)
213 uc.recentMu.Lock()
214 delete(uc.recentVisits, paneID)
215 uc.recentMu.Unlock()
216}
217
218// historyWorker is a background goroutine that processes history records.
219// It drains the queue and persists records to the database without blocking the UI.
220func (uc *NavigateUseCase) historyWorker() {
221 defer uc.wg.Done()
222
223 log := logging.FromContext(uc.ctx).With().
224 Str("component", "history-worker").
225 Logger()
226
227 ticker := time.NewTicker(historyWorkerFlushInterval)
228 defer ticker.Stop()
229
230 pending := make(map[string]int)
231
232 flushPending := func() {
233 if len(pending) == 0 {
234 return
235 }
236 for historyURL, visits := range pending {
237 uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits})
238 }
239 clear(pending)
240 }
241
242 drainQueue := func() {
243 for {
244 select {
245 case record := <-uc.historyQueue:
246 pending[record.url] += record.visits
247 default:
248 return
249 }
250 }
251 }
252
253 for {
254 select {
255 case record := <-uc.historyQueue:
256 pending[record.url] += record.visits
257 case <-ticker.C:
258 flushPending()
259 case <-uc.done:
260 log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
261 drainQueue()
262 flushPending()
263 log.Debug().Msg("history worker shutdown complete")
264 return
265 }
266 }
267}
268
269// persistHistory writes a history record to the database.
270// Called from the background worker goroutine.
271func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRecord) {
272 log := logging.FromContext(ctx)
273
274 // Check if entry exists
275 existing, err := uc.historyRepo.FindByURL(ctx, record.url)
276 if err != nil {
277 log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")
278 return
279 }
280
281 if existing != nil {
282 delta := max(1, record.visits)
283 if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
284 if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
285 log.Warn().Err(err).Str("url", record.url).Int("delta", delta).Msg("failed to increment visit count by delta")
286 return
287 }
288 return
289 }
290 iterations := delta
291 if iterations > historyFallbackIncrementCap {
292 log.Warn().
293 Str("url", record.url).
294 Int("delta", delta).
295 Int("cap", historyFallbackIncrementCap).
296 Msg("visit count fallback increment capped")
297 iterations = historyFallbackIncrementCap
298 }
299 for i := 0; i < iterations; i++ {
300 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
301 log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
302 return
303 }
304 }
305 } else {
306 // Create new entry
307 entry := entity.NewHistoryEntry(record.url, "")
308 entry.VisitCount = int64(max(1, record.visits))
309 if err := uc.historyRepo.Save(ctx, entry); err != nil {
310 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
311 }
312 }
313}
314
315// UpdateHistoryTitle updates the title of a history entry after page load.
316func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, title string) error {
317 log := logging.FromContext(ctx)
318
319 // Canonicalize URL the same way history records are persisted.
320 historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
321 StripTrackingParams: stripTrackingParamsForHistoryDedup,
322 })
323 if historyURL == "" {
324 log.Debug().Str("title", title).Msg("history URL empty after canonicalization, skipping title update")
325 return nil
326 }
327 log.Debug().Str("url", logging.TruncateURL(historyURL, logURLMaxLen)).Str("title", title).Msg("updating history title")
328
329 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
330 if err != nil {
331 return fmt.Errorf("failed to find history entry: %w", err)
332 }
333
334 if entry == nil {
335 // Entry doesn't exist - don't create it here
336 // Initial navigation should have already created the entry
337 // This can happen if URL changed during page load (SPA, redirect)
338 log.Debug().Str("url", historyURL).Msg("no history entry found for URL, skipping title update")
339 return nil
340 }
341
342 // Update existing entry's title
343 entry.Title = title
344 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
345 if err := updater.UpdateMetadata(ctx, entry); err != nil {
346 return fmt.Errorf("failed to update history metadata: %w", err)
347 }
348 return nil
349 }
350 if err := uc.historyRepo.Save(ctx, entry); err != nil {
351 return fmt.Errorf("failed to update history title: %w", err)
352 }
353
354 return nil
355}
356
357// Reload reloads the current page.
358func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error {
359 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
360 log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page")
361
362 if bypassCache {
363 return webview.ReloadBypassCache(ctx)
364 }
365 return webview.Reload(ctx)
366}
367
368// Stop stops the current page load.
369func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
370 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
371 log.Debug().Msg("stopping page load")
372 return webview.Stop(ctx)
373}
374
375func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
376 raw = strings.TrimSpace(raw)
377 if raw == "" {
378 return ""
379 }
380
381 parsed, err := url.Parse(raw)
382 if err != nil {
383 return strings.TrimSuffix(raw, "/")
384 }
385
386 parsed.Scheme = strings.ToLower(parsed.Scheme)
387 parsed.Host = strings.ToLower(parsed.Host)
388 parsed.Fragment = ""
389 parsed.Path = normalizePathForHistory(parsed.Path)
390
391 query := parsed.Query()
392 if opts.StripTrackingParams {
393 for key := range query {
394 if isTrackingQueryParam(key) {
395 query.Del(key)
396 }
397 }
398 }
399 parsed.RawQuery = query.Encode()
400
401 return parsed.String()
402}
403
404func normalizePathForHistory(path string) string {
405 if path == "/" {
406 return ""
407 }
408 if strings.HasSuffix(path, "/") {
409 return strings.TrimSuffix(path, "/")
410 }
411 return path
412}
413
414func isTrackingQueryParam(key string) bool {
415 key = strings.ToLower(strings.TrimSpace(key))
416 if key == "" {
417 return false
418 }
419 if strings.HasPrefix(key, "utm_") {
420 return true
421 }
422 switch key {
423 case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
424 return true
425 }
426 return false
427}
428
429func isHashOnlyTransition(previous, current string) bool {
430 if previous == "" || current == "" || previous == current {
431 return false
432 }
433
434 prevParsed, prevErr := url.Parse(previous)
435 currParsed, currErr := url.Parse(current)
436 if prevErr != nil || currErr != nil {
437 return false
438 }
439
440 if !strings.EqualFold(prevParsed.Scheme, currParsed.Scheme) {
441 return false
442 }
443 if !strings.EqualFold(prevParsed.Host, currParsed.Host) {
444 return false
445 }
446 if normalizePathForHistory(prevParsed.Path) != normalizePathForHistory(currParsed.Path) {
447 return false
448 }
449 if prevParsed.RawQuery != currParsed.RawQuery {
450 return false
451 }
452 return prevParsed.Fragment != currParsed.Fragment
453}
454
455func normalizePaneID(paneID string) string {
456 if strings.TrimSpace(paneID) == "" {
457 return "__default__"
458 }
459 return paneID
460}