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