1package usecase
2
3import (
4 "context"
5 "errors"
6 "net"
7 "net/url"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/bnema/dumber/internal/application/dto"
13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/domain/entity"
15 "github.com/bnema/dumber/internal/domain/repository"
16 "github.com/bnema/dumber/internal/logging"
17 "github.com/rs/zerolog"
18)
19
20const (
21 // historyQueueSize is the buffer size for the async history queue.
22 // If the queue is full, new records are dropped with a warning.
23 historyQueueSize = 100
24
25 // historyWorkerFlushInterval coalesces bursts into fewer persistence writes.
26 historyWorkerFlushInterval = 100 * time.Millisecond
27
28 // historyWorkerShutdownFlushTimeout bounds final persistence attempts during shutdown.
29 historyWorkerShutdownFlushTimeout = 2 * time.Second
30
31 // historyWorkerShutdownWaitTimeout bounds Close even if a repository call ignores context cancellation.
32 historyWorkerShutdownWaitTimeout = historyWorkerShutdownFlushTimeout + 500*time.Millisecond
33)
34
35// historyDeduplicationWindow is the time window for deduplicating history visits.
36// Visits to the same URL within this window count as a single visit.
37// This prevents inflation from redirects and rapid navigation.
38const historyDeduplicationWindow = 2 * time.Second
39
40// stripTrackingParamsForHistoryDedup controls whether known tracking parameters
41// are removed while canonicalizing URLs for history deduplication/storage.
42const stripTrackingParamsForHistoryDedup = true
43
44// historyRecord holds data for async history recording.
45type historyRecord struct {
46 url string
47 title string // non-empty for title-update-only records
48 visits int
49}
50
51type historyVisitDeltaIncrementer interface {
52 IncrementVisitCountBy(ctx context.Context, url string, delta int) error
53}
54
55type historyMetadataUpdater interface {
56 UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error
57}
58
59type paneHistoryState struct {
60 lastRawURL string
61 lastCanonicalURL string
62 lastRecordedAt time.Time
63}
64
65type historyCanonicalizationOptions struct {
66 StripTrackingParams bool
67}
68
69type historyControlRequest struct {
70 kind historyControlKind
71 ctx context.Context
72 done chan error
73}
74
75type historyControlKind int
76
77type titlePersistenceResult int
78
79const historyControlFlushAndReset historyControlKind = iota
80
81const (
82 titlePersistenceRetry titlePersistenceResult = iota
83 titlePersistenceSaved
84 titlePersistenceDiscard
85)
86
87var (
88 errHistoryRecorderClosing = errors.New("history recorder closing")
89 errHistoryPersistenceIncomplete = errors.New("history persistence incomplete")
90)
91
92type pendingHistoryRecords struct {
93 visits map[string]int
94 titles map[string]string
95}
96
97func newPendingHistoryRecords() *pendingHistoryRecords {
98 return &pendingHistoryRecords{
99 visits: make(map[string]int),
100 titles: make(map[string]string),
101 }
102}
103
104func (p *pendingHistoryRecords) add(record historyRecord) {
105 if record.title != "" {
106 p.titles[record.url] = record.title
107 }
108 if record.visits > 0 {
109 p.visits[record.url] += record.visits
110 }
111}
112
113func (p *pendingHistoryRecords) empty() bool {
114 return len(p.visits) == 0 && len(p.titles) == 0
115}
116
117func (p *pendingHistoryRecords) reset() {
118 p.visits = make(map[string]int)
119 p.titles = make(map[string]string)
120}
121
122// HistoryRecorderUseCase records persisted history visits and title updates asynchronously.
123type HistoryRecorderUseCase struct {
124 historyRepo repository.HistoryRepository
125 changeSinkMu sync.RWMutex
126 changeSink port.HistoryChangeSink
127 recentMu sync.Mutex
128 recentVisits map[string]paneHistoryState // key: pane ID, value: per-pane history state
129 mutationMu sync.Mutex
130 closeOnce sync.Once
131 enqueueMu sync.Mutex
132 closing bool
133
134 historyQueue chan historyRecord
135 controlQueue chan historyControlRequest
136 done chan struct{}
137 wg sync.WaitGroup
138 ctx context.Context // Base context for background worker
139 cancel context.CancelFunc
140}
141
142// NewHistoryRecorderUseCase creates a new history recorder use case.
143func NewHistoryRecorderUseCase(historyRepo repository.HistoryRepository, changeSink port.HistoryChangeSink) *HistoryRecorderUseCase {
144 changeSink = normalizeHistoryChangeSink(changeSink)
145 ctx, cancel := context.WithCancel(context.Background())
146
147 uc := &HistoryRecorderUseCase{
148 historyRepo: historyRepo,
149 changeSink: changeSink,
150 recentVisits: make(map[string]paneHistoryState),
151 historyQueue: make(chan historyRecord, historyQueueSize),
152 controlQueue: make(chan historyControlRequest),
153 done: make(chan struct{}),
154 ctx: ctx,
155 cancel: cancel,
156 }
157
158 uc.wg.Add(1)
159 go uc.historyWorker()
160
161 return uc
162}
163
164// SetHistoryChangeSink sets the sink for persisted history change notifications.
165func (uc *HistoryRecorderUseCase) SetHistoryChangeSink(changeSink port.HistoryChangeSink) {
166 if uc == nil {
167 return
168 }
169 changeSink = normalizeHistoryChangeSink(changeSink)
170 uc.changeSinkMu.Lock()
171 uc.changeSink = changeSink
172 uc.changeSinkMu.Unlock()
173}
174
175// Close shuts down the background history worker and drains any pending records.
176func (uc *HistoryRecorderUseCase) Close() {
177 if uc == nil {
178 return
179 }
180 uc.closeOnce.Do(func() {
181 uc.enqueueMu.Lock()
182 uc.closing = true
183 uc.enqueueMu.Unlock()
184 uc.cancelWorkerContext()
185 if uc.done != nil {
186 close(uc.done)
187 }
188 })
189 if uc.waitForHistoryWorker(historyWorkerShutdownWaitTimeout) {
190 return
191 }
192 logging.FromContext(uc.workerContext()).Warn().
193 Dur("timeout", historyWorkerShutdownWaitTimeout).
194 Msg("history worker shutdown timed out")
195}
196
197func (uc *HistoryRecorderUseCase) workerContext() context.Context {
198 if uc == nil || uc.ctx == nil {
199 return context.Background()
200 }
201 return uc.ctx
202}
203
204func (uc *HistoryRecorderUseCase) cancelWorkerContext() {
205 if uc != nil && uc.cancel != nil {
206 uc.cancel()
207 }
208}
209
210func (uc *HistoryRecorderUseCase) waitForHistoryWorker(timeout time.Duration) bool {
211 done := make(chan struct{})
212 go func() {
213 uc.wg.Wait()
214 close(done)
215 }()
216 if timeout <= 0 {
217 <-done
218 return true
219 }
220 timer := time.NewTimer(timeout)
221 defer timer.Stop()
222 select {
223 case <-done:
224 return true
225 case <-timer.C:
226 return false
227 }
228}
229
230// RecordHistory queues a history entry for async recording.
231// This is non-blocking to avoid SQLite I/O on the GTK main thread.
232// Should be called on LoadCommitted when URI is guaranteed correct.
233func (uc *HistoryRecorderUseCase) RecordHistory(ctx context.Context, paneID, rawURL string) {
234 if uc == nil {
235 return
236 }
237 log := logging.FromContext(ctx)
238 rawURL = strings.TrimSpace(rawURL)
239 if rawURL == "" {
240 return
241 }
242
243 if !isRecordableHistoryURL(rawURL) {
244 return
245 }
246
247 canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
248 StripTrackingParams: stripTrackingParamsForHistoryDedup,
249 })
250 if canonicalURL == "" {
251 return
252 }
253
254 paneID = normalizePaneID(paneID)
255
256 now := time.Now()
257
258 uc.mutationMu.Lock()
259 defer uc.mutationMu.Unlock()
260
261 uc.recentMu.Lock()
262 state := uc.recentVisits[paneID]
263
264 // Ignore in-page hash transitions for history persistence.
265 if isHashOnlyTransition(state.lastRawURL, rawURL) {
266 state.lastRawURL = rawURL
267 uc.recentVisits[paneID] = state
268 uc.recentMu.Unlock()
269 return
270 }
271
272 // Per-pane short-window deduplication by canonical URL.
273 if state.lastCanonicalURL == canonicalURL && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
274 state.lastRawURL = rawURL
275 uc.recentVisits[paneID] = state
276 uc.recentMu.Unlock()
277 return
278 }
279
280 state.lastRawURL = rawURL
281 state.lastCanonicalURL = canonicalURL
282 state.lastRecordedAt = now
283 enqueued := uc.enqueueHistoryRecord(historyRecord{url: canonicalURL, visits: 1})
284 if enqueued {
285 uc.recentVisits[paneID] = state
286 }
287 uc.recentMu.Unlock()
288
289 if !enqueued {
290 log.Warn().Str("url", logging.RedactURL(canonicalURL)).Msg("history queue full or recorder closed, dropping record")
291 }
292}
293
294// ClearPaneHistory removes per-pane deduplication state to prevent unbounded growth.
295// Callers should invoke this when a pane is closed.
296func (uc *HistoryRecorderUseCase) ClearPaneHistory(paneID string) {
297 if uc == nil {
298 return
299 }
300 paneID = normalizePaneID(paneID)
301 uc.mutationMu.Lock()
302 defer uc.mutationMu.Unlock()
303 uc.recentMu.Lock()
304 delete(uc.recentVisits, paneID)
305 uc.recentMu.Unlock()
306}
307
308// UpdateHistoryTitle queues a title update for a history entry. The actual
309// DB write happens in the background historyWorker to avoid concurrent
310// repo access from the GTK main thread and the worker goroutine.
311func (uc *HistoryRecorderUseCase) UpdateHistoryTitle(_ context.Context, historyURL, title string) {
312 if uc == nil {
313 return
314 }
315 historyURL = strings.TrimSpace(historyURL)
316 if historyURL == "" || !isRecordableHistoryURL(historyURL) {
317 return
318 }
319 historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
320 StripTrackingParams: stripTrackingParamsForHistoryDedup,
321 })
322 if historyURL == "" {
323 return
324 }
325
326 uc.mutationMu.Lock()
327 defer uc.mutationMu.Unlock()
328
329 if !uc.enqueueHistoryRecord(historyRecord{url: historyURL, title: title}) {
330 logging.FromContext(uc.workerContext()).Warn().
331 Str("url", logging.RedactURL(historyURL)).
332 Msg("history queue full or recorder closed, title update dropped")
333 }
334}
335
336func (uc *HistoryRecorderUseCase) enqueueHistoryRecord(record historyRecord) bool {
337 uc.enqueueMu.Lock()
338 defer uc.enqueueMu.Unlock()
339 if uc.closing {
340 return false
341 }
342 select {
343 case uc.historyQueue <- record:
344 return true
345 default:
346 return false
347 }
348}
349
350// historyWorker is a background goroutine that processes history records.
351// It drains the queue and persists records to the database without blocking the UI.
352func (uc *HistoryRecorderUseCase) historyWorker() {
353 defer uc.wg.Done()
354
355 ticker := time.NewTicker(historyWorkerFlushInterval)
356 defer ticker.Stop()
357 workerCtx := uc.workerContext()
358 // queryCtx carries the worker's values (logging) but is detached from
359 // worker cancellation. Every database/sql query derives per-query child
360 // contexts from its parent; deriving those children from workerCtx while
361 // Close cancels workerCtx intermittently crashed in
362 // context.propagateCancel (concurrent map writes, #408). Shutdown
363 // coordination stays on uc.done/workerCtx.Done() in the select below,
364 // and the shutdown drain uses its own bounded timeout context.
365 queryCtx := context.WithoutCancel(workerCtx)
366
367 pending := newPendingHistoryRecords()
368 for {
369 select {
370 case record := <-uc.historyQueue:
371 pending.add(record)
372 case <-ticker.C:
373 _ = uc.flushPendingHistory(queryCtx, pending)
374 case req := <-uc.controlQueue:
375 uc.handleHistoryControl(pending, req)
376 case <-uc.done:
377 uc.shutdownHistoryWorker(pending)
378 return
379 case <-workerCtx.Done():
380 uc.shutdownHistoryWorker(pending)
381 return
382 }
383 }
384}
385
386func (uc *HistoryRecorderUseCase) flushPendingHistory(ctx context.Context, pending *pendingHistoryRecords) error {
387 if ctx == nil {
388 ctx = uc.workerContext()
389 }
390 if pending.empty() {
391 return nil
392 }
393
394 visitCount := len(pending.visits)
395 titleCount := len(pending.titles)
396 logging.FromContext(ctx).Debug().
397 Int("visits", visitCount).
398 Int("titles", titleCount).
399 Msg("flushing pending history")
400
401 var flushErr error
402 successfulVisits := 0
403 for historyURL, visits := range pending.visits {
404 if err := ctx.Err(); err != nil {
405 uc.publishHistoryChange(successfulVisits, 0)
406 return err
407 }
408 record := historyRecord{url: historyURL, visits: visits}
409 writtenVisits := uc.persistHistory(ctx, record)
410 if writtenVisits > 0 {
411 successfulVisits += writtenVisits
412 if writtenVisits >= visits {
413 delete(pending.visits, historyURL)
414 } else {
415 pending.visits[historyURL] = visits - writtenVisits
416 }
417 }
418 if writtenVisits < visits {
419 flushErr = errHistoryPersistenceIncomplete
420 }
421 }
422
423 successfulTitles := 0
424 for historyURL, title := range pending.titles {
425 if err := ctx.Err(); err != nil {
426 uc.publishHistoryChange(successfulVisits, successfulTitles)
427 return err
428 }
429 if _, visitStillPending := pending.visits[historyURL]; visitStillPending {
430 flushErr = errHistoryPersistenceIncomplete
431 continue
432 }
433 switch uc.persistTitleUpdate(ctx, historyURL, title) {
434 case titlePersistenceSaved:
435 successfulTitles++
436 delete(pending.titles, historyURL)
437 case titlePersistenceDiscard:
438 delete(pending.titles, historyURL)
439 case titlePersistenceRetry:
440 flushErr = errHistoryPersistenceIncomplete
441 }
442 }
443 uc.publishHistoryChange(successfulVisits, successfulTitles)
444 if flushErr != nil {
445 return flushErr
446 }
447 return ctx.Err()
448}
449
450func (uc *HistoryRecorderUseCase) handleHistoryControl(pending *pendingHistoryRecords, req historyControlRequest) {
451 var err error
452 switch req.kind {
453 case historyControlFlushAndReset:
454 uc.drainHistoryQueue(pending)
455 err = uc.flushPendingHistory(req.ctx, pending)
456 if err == nil {
457 uc.resetRecentVisits()
458 }
459 }
460 req.done <- err
461 close(req.done)
462}
463
464func (uc *HistoryRecorderUseCase) drainHistoryQueue(pending *pendingHistoryRecords) {
465 for {
466 select {
467 case record := <-uc.historyQueue:
468 pending.add(record)
469 default:
470 return
471 }
472 }
473}
474
475func (uc *HistoryRecorderUseCase) shutdownHistoryWorker(pending *pendingHistoryRecords) {
476 log := logging.FromContext(uc.workerContext()).With().
477 Str("component", "history-worker").
478 Logger()
479 shutdownCtx, cancel := context.WithTimeout(context.Background(), historyWorkerShutdownFlushTimeout)
480 defer cancel()
481
482 for {
483 if err := shutdownCtx.Err(); err != nil {
484 if !pending.empty() {
485 log.Warn().Err(err).Msg("dropping unflushed history during shutdown after timeout")
486 pending.reset()
487 }
488 return
489 }
490 log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
491 uc.drainHistoryQueue(pending)
492 uc.flushPendingHistoryForShutdown(shutdownCtx, log, pending)
493
494 uc.enqueueMu.Lock()
495 uc.drainHistoryQueue(pending)
496 if pending.empty() && len(uc.historyQueue) == 0 {
497 uc.closing = true
498 uc.enqueueMu.Unlock()
499 log.Debug().Msg("history worker shutdown complete")
500 return
501 }
502 uc.enqueueMu.Unlock()
503 }
504}
505
506func (uc *HistoryRecorderUseCase) flushPendingHistoryForShutdown(ctx context.Context, log zerolog.Logger, pending *pendingHistoryRecords) {
507 const shutdownFlushAttempts = 3
508 for attempt := 1; attempt <= shutdownFlushAttempts; attempt++ {
509 err := uc.flushPendingHistory(ctx, pending)
510 if err == nil || pending.empty() {
511 return
512 }
513 if attempt == shutdownFlushAttempts {
514 log.Warn().Err(err).Msg("dropping unflushed history during shutdown after retries")
515 pending.reset()
516 return
517 }
518 log.Warn().Err(err).Int("attempt", attempt).Msg("retrying unflushed history during shutdown")
519 select {
520 case <-ctx.Done():
521 log.Warn().Err(ctx.Err()).Msg("dropping unflushed history during shutdown after timeout")
522 pending.reset()
523 return
524 case <-time.After(historyWorkerFlushInterval):
525 }
526 }
527}
528
529// BeginHistoryMutation flushes pending recorder writes and blocks new recorder enqueues until release is called.
530func (uc *HistoryRecorderUseCase) BeginHistoryMutation(ctx context.Context) (func(), error) {
531 if uc == nil {
532 return func() {}, nil
533 }
534 if ctx == nil {
535 ctx = context.Background()
536 }
537 uc.mutationMu.Lock()
538 if err := uc.flushAndResetHistory(ctx); err != nil {
539 uc.mutationMu.Unlock()
540 if errors.Is(err, errHistoryRecorderClosing) {
541 uc.wg.Wait()
542 uc.resetRecentVisits()
543 return func() {}, nil
544 }
545 return nil, err
546 }
547 return func() { uc.mutationMu.Unlock() }, nil
548}
549
550func (uc *HistoryRecorderUseCase) flushAndResetHistory(ctx context.Context) error {
551 req := historyControlRequest{
552 kind: historyControlFlushAndReset,
553 ctx: ctx,
554 done: make(chan error, 1),
555 }
556 select {
557 case uc.controlQueue <- req:
558 select {
559 case err := <-req.done:
560 return err
561 case <-uc.done:
562 return uc.waitForAcceptedFlushOrShutdown(ctx, req.done)
563 case <-ctx.Done():
564 return ctx.Err()
565 }
566 case <-uc.done:
567 return errHistoryRecorderClosing
568 case <-ctx.Done():
569 return ctx.Err()
570 }
571}
572
573func (uc *HistoryRecorderUseCase) waitForAcceptedFlushOrShutdown(ctx context.Context, reqDone <-chan error) error {
574 wgDone := make(chan struct{})
575 go func() {
576 uc.wg.Wait()
577 close(wgDone)
578 }()
579
580 select {
581 case err := <-reqDone:
582 return err
583 case <-wgDone:
584 uc.resetRecentVisits()
585 return errHistoryRecorderClosing
586 case <-ctx.Done():
587 return ctx.Err()
588 }
589}
590
591func (uc *HistoryRecorderUseCase) resetRecentVisits() {
592 uc.recentMu.Lock()
593 uc.recentVisits = make(map[string]paneHistoryState)
594 uc.recentMu.Unlock()
595}
596
597func (uc *HistoryRecorderUseCase) publishHistoryChange(successfulVisits, successfulTitles int) {
598 if successfulVisits == 0 && successfulTitles == 0 {
599 return
600 }
601 reasons := make([]dto.HistoryChangeReason, 0, 2)
602 if successfulVisits > 0 {
603 reasons = append(reasons, dto.HistoryChangeReasonVisit)
604 }
605 if successfulTitles > 0 {
606 reasons = append(reasons, dto.HistoryChangeReasonTitle)
607 }
608 sink := uc.historyChangeSink()
609 // Detach the notification from worker cancellation: the sink runs on
610 // another goroutine (main-thread dispatch) and must never derive from
611 // or observe a parent that Close cancels concurrently (see #408).
612 sink.OnHistoryChanged(context.WithoutCancel(uc.workerContext()), dto.HistoryChange{
613 Reasons: reasons,
614 VisitCount: successfulVisits,
615 TitleCount: successfulTitles,
616 ChangedAt: time.Now(),
617 })
618}
619
620func (uc *HistoryRecorderUseCase) historyChangeSink() port.HistoryChangeSink {
621 uc.changeSinkMu.RLock()
622 sink := uc.changeSink
623 uc.changeSinkMu.RUnlock()
624 return normalizeHistoryChangeSink(sink)
625}
626
627// persistHistory writes a history record to the database.
628// Called from the background worker goroutine.
629func (uc *HistoryRecorderUseCase) persistHistory(ctx context.Context, record historyRecord) int {
630 log := logging.FromContext(ctx)
631
632 existing, err := uc.historyRepo.FindByURL(ctx, record.url)
633 if err != nil {
634 log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history")
635 return 0
636 }
637
638 delta := max(1, record.visits)
639 if existing != nil {
640 if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
641 if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
642 log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta")
643 return 0
644 }
645 return delta
646 }
647 written := 0
648 for range delta {
649 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
650 log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count")
651 return written
652 }
653 written++
654 }
655 return written
656 }
657
658 entry := entity.NewHistoryEntry(record.url, "")
659 entry.VisitCount = int64(delta)
660 if err := uc.historyRepo.Save(ctx, entry); err != nil {
661 log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history")
662 return 0
663 }
664 return delta
665}
666
667// persistTitleUpdate writes a title update to the database.
668// Called from the background worker goroutine.
669func (uc *HistoryRecorderUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) titlePersistenceResult {
670 log := logging.FromContext(ctx)
671
672 if title == "" {
673 return titlePersistenceDiscard
674 }
675
676 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
677 if err != nil {
678 log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update")
679 return titlePersistenceRetry
680 }
681 if entry == nil {
682 return titlePersistenceDiscard
683 }
684
685 entry.Title = title
686 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
687 if err := updater.UpdateMetadata(ctx, entry); err != nil {
688 log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata")
689 return titlePersistenceRetry
690 }
691 return titlePersistenceSaved
692 }
693 if err := uc.historyRepo.Save(ctx, entry); err != nil {
694 log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title")
695 return titlePersistenceRetry
696 }
697 return titlePersistenceSaved
698}
699
700func isRecordableHistoryURL(raw string) bool {
701 parsed, err := url.Parse(strings.TrimSpace(raw))
702 if err != nil {
703 return false
704 }
705 if strings.TrimSpace(parsed.Host) == "" {
706 return false
707 }
708 switch strings.ToLower(parsed.Scheme) {
709 case "http", "https":
710 return true
711 default:
712 return false
713 }
714}
715
716func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
717 raw = strings.TrimSpace(raw)
718 if raw == "" {
719 return ""
720 }
721
722 parsed, err := url.Parse(raw)
723 if err != nil {
724 return strings.TrimSuffix(raw, "/")
725 }
726
727 parsed.Scheme = strings.ToLower(parsed.Scheme)
728 parsed.Host = canonicalHistoryHost(parsed)
729 parsed.User = nil
730 parsed.Fragment = ""
731 parsed.Path = normalizePathForHistory(parsed.Path)
732
733 query := parsed.Query()
734 if opts.StripTrackingParams {
735 for key := range query {
736 if isTrackingQueryParam(key) {
737 query.Del(key)
738 }
739 }
740 }
741 parsed.RawQuery = query.Encode()
742
743 return parsed.String()
744}
745
746func canonicalHistoryHost(parsed *url.URL) string {
747 host := strings.ToLower(parsed.Hostname())
748 portValue := parsed.Port()
749 if portValue == "" || (parsed.Scheme == "http" && portValue == "80") || (parsed.Scheme == "https" && portValue == "443") {
750 if strings.Contains(host, ":") {
751 return "[" + host + "]"
752 }
753 return host
754 }
755 return net.JoinHostPort(host, portValue)
756}
757
758func normalizePathForHistory(path string) string {
759 if path == "/" {
760 return ""
761 }
762 if before, ok := strings.CutSuffix(path, "/"); ok {
763 return before
764 }
765 return path
766}
767
768func isTrackingQueryParam(key string) bool {
769 key = strings.ToLower(strings.TrimSpace(key))
770 if key == "" {
771 return false
772 }
773 if strings.HasPrefix(key, "utm_") {
774 return true
775 }
776 switch key {
777 case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
778 return true
779 }
780 return false
781}
782
783func isHashOnlyTransition(previous, current string) bool {
784 if previous == "" || current == "" || previous == current {
785 return false
786 }
787
788 prevParsed, prevErr := url.Parse(previous)
789 currParsed, currErr := url.Parse(current)
790 if prevErr != nil || currErr != nil {
791 return false
792 }
793
794 if prevParsed.Fragment == currParsed.Fragment {
795 return false
796 }
797
798 opts := historyCanonicalizationOptions{StripTrackingParams: stripTrackingParamsForHistoryDedup}
799 prevCanonical := canonicalizeURLForHistory(previous, opts)
800 currCanonical := canonicalizeURLForHistory(current, opts)
801 return prevCanonical != "" && prevCanonical == currCanonical
802}
803
804func normalizePaneID(paneID string) string {
805 if strings.TrimSpace(paneID) == "" {
806 return "__default__"
807 }
808 return paneID
809}