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
359 pending := newPendingHistoryRecords()
360 for {
361 select {
362 case record := <-uc.historyQueue:
363 pending.add(record)
364 case <-ticker.C:
365 _ = uc.flushPendingHistory(workerCtx, pending)
366 case req := <-uc.controlQueue:
367 uc.handleHistoryControl(pending, req)
368 case <-uc.done:
369 uc.shutdownHistoryWorker(pending)
370 return
371 case <-workerCtx.Done():
372 uc.shutdownHistoryWorker(pending)
373 return
374 }
375 }
376}
377
378func (uc *HistoryRecorderUseCase) flushPendingHistory(ctx context.Context, pending *pendingHistoryRecords) error {
379 if ctx == nil {
380 ctx = uc.workerContext()
381 }
382 if pending.empty() {
383 return nil
384 }
385
386 visitCount := len(pending.visits)
387 titleCount := len(pending.titles)
388 logging.FromContext(ctx).Debug().
389 Int("visits", visitCount).
390 Int("titles", titleCount).
391 Msg("flushing pending history")
392
393 var flushErr error
394 successfulVisits := 0
395 for historyURL, visits := range pending.visits {
396 if err := ctx.Err(); err != nil {
397 uc.publishHistoryChange(successfulVisits, 0)
398 return err
399 }
400 record := historyRecord{url: historyURL, visits: visits}
401 writtenVisits := uc.persistHistory(ctx, record)
402 if writtenVisits > 0 {
403 successfulVisits += writtenVisits
404 if writtenVisits >= visits {
405 delete(pending.visits, historyURL)
406 } else {
407 pending.visits[historyURL] = visits - writtenVisits
408 }
409 }
410 if writtenVisits < visits {
411 flushErr = errHistoryPersistenceIncomplete
412 }
413 }
414
415 successfulTitles := 0
416 for historyURL, title := range pending.titles {
417 if err := ctx.Err(); err != nil {
418 uc.publishHistoryChange(successfulVisits, successfulTitles)
419 return err
420 }
421 if _, visitStillPending := pending.visits[historyURL]; visitStillPending {
422 flushErr = errHistoryPersistenceIncomplete
423 continue
424 }
425 switch uc.persistTitleUpdate(ctx, historyURL, title) {
426 case titlePersistenceSaved:
427 successfulTitles++
428 delete(pending.titles, historyURL)
429 case titlePersistenceDiscard:
430 delete(pending.titles, historyURL)
431 case titlePersistenceRetry:
432 flushErr = errHistoryPersistenceIncomplete
433 }
434 }
435 uc.publishHistoryChange(successfulVisits, successfulTitles)
436 if flushErr != nil {
437 return flushErr
438 }
439 return ctx.Err()
440}
441
442func (uc *HistoryRecorderUseCase) handleHistoryControl(pending *pendingHistoryRecords, req historyControlRequest) {
443 var err error
444 switch req.kind {
445 case historyControlFlushAndReset:
446 uc.drainHistoryQueue(pending)
447 err = uc.flushPendingHistory(req.ctx, pending)
448 if err == nil {
449 uc.resetRecentVisits()
450 }
451 }
452 req.done <- err
453 close(req.done)
454}
455
456func (uc *HistoryRecorderUseCase) drainHistoryQueue(pending *pendingHistoryRecords) {
457 for {
458 select {
459 case record := <-uc.historyQueue:
460 pending.add(record)
461 default:
462 return
463 }
464 }
465}
466
467func (uc *HistoryRecorderUseCase) shutdownHistoryWorker(pending *pendingHistoryRecords) {
468 log := logging.FromContext(uc.workerContext()).With().
469 Str("component", "history-worker").
470 Logger()
471 shutdownCtx, cancel := context.WithTimeout(context.Background(), historyWorkerShutdownFlushTimeout)
472 defer cancel()
473
474 for {
475 if err := shutdownCtx.Err(); err != nil {
476 if !pending.empty() {
477 log.Warn().Err(err).Msg("dropping unflushed history during shutdown after timeout")
478 pending.reset()
479 }
480 return
481 }
482 log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
483 uc.drainHistoryQueue(pending)
484 uc.flushPendingHistoryForShutdown(shutdownCtx, log, pending)
485
486 uc.enqueueMu.Lock()
487 uc.drainHistoryQueue(pending)
488 if pending.empty() && len(uc.historyQueue) == 0 {
489 uc.closing = true
490 uc.enqueueMu.Unlock()
491 log.Debug().Msg("history worker shutdown complete")
492 return
493 }
494 uc.enqueueMu.Unlock()
495 }
496}
497
498func (uc *HistoryRecorderUseCase) flushPendingHistoryForShutdown(ctx context.Context, log zerolog.Logger, pending *pendingHistoryRecords) {
499 const shutdownFlushAttempts = 3
500 for attempt := 1; attempt <= shutdownFlushAttempts; attempt++ {
501 err := uc.flushPendingHistory(ctx, pending)
502 if err == nil || pending.empty() {
503 return
504 }
505 if attempt == shutdownFlushAttempts {
506 log.Warn().Err(err).Msg("dropping unflushed history during shutdown after retries")
507 pending.reset()
508 return
509 }
510 log.Warn().Err(err).Int("attempt", attempt).Msg("retrying unflushed history during shutdown")
511 select {
512 case <-ctx.Done():
513 log.Warn().Err(ctx.Err()).Msg("dropping unflushed history during shutdown after timeout")
514 pending.reset()
515 return
516 case <-time.After(historyWorkerFlushInterval):
517 }
518 }
519}
520
521// BeginHistoryMutation flushes pending recorder writes and blocks new recorder enqueues until release is called.
522func (uc *HistoryRecorderUseCase) BeginHistoryMutation(ctx context.Context) (func(), error) {
523 if uc == nil {
524 return func() {}, nil
525 }
526 if ctx == nil {
527 ctx = context.Background()
528 }
529 uc.mutationMu.Lock()
530 if err := uc.flushAndResetHistory(ctx); err != nil {
531 uc.mutationMu.Unlock()
532 if errors.Is(err, errHistoryRecorderClosing) {
533 uc.wg.Wait()
534 uc.resetRecentVisits()
535 return func() {}, nil
536 }
537 return nil, err
538 }
539 return func() { uc.mutationMu.Unlock() }, nil
540}
541
542func (uc *HistoryRecorderUseCase) flushAndResetHistory(ctx context.Context) error {
543 req := historyControlRequest{
544 kind: historyControlFlushAndReset,
545 ctx: ctx,
546 done: make(chan error, 1),
547 }
548 select {
549 case uc.controlQueue <- req:
550 select {
551 case err := <-req.done:
552 return err
553 case <-uc.done:
554 return uc.waitForAcceptedFlushOrShutdown(ctx, req.done)
555 case <-ctx.Done():
556 return ctx.Err()
557 }
558 case <-uc.done:
559 return errHistoryRecorderClosing
560 case <-ctx.Done():
561 return ctx.Err()
562 }
563}
564
565func (uc *HistoryRecorderUseCase) waitForAcceptedFlushOrShutdown(ctx context.Context, reqDone <-chan error) error {
566 wgDone := make(chan struct{})
567 go func() {
568 uc.wg.Wait()
569 close(wgDone)
570 }()
571
572 select {
573 case err := <-reqDone:
574 return err
575 case <-wgDone:
576 uc.resetRecentVisits()
577 return errHistoryRecorderClosing
578 case <-ctx.Done():
579 return ctx.Err()
580 }
581}
582
583func (uc *HistoryRecorderUseCase) resetRecentVisits() {
584 uc.recentMu.Lock()
585 uc.recentVisits = make(map[string]paneHistoryState)
586 uc.recentMu.Unlock()
587}
588
589func (uc *HistoryRecorderUseCase) publishHistoryChange(successfulVisits, successfulTitles int) {
590 if successfulVisits == 0 && successfulTitles == 0 {
591 return
592 }
593 reasons := make([]dto.HistoryChangeReason, 0, 2)
594 if successfulVisits > 0 {
595 reasons = append(reasons, dto.HistoryChangeReasonVisit)
596 }
597 if successfulTitles > 0 {
598 reasons = append(reasons, dto.HistoryChangeReasonTitle)
599 }
600 sink := uc.historyChangeSink()
601 sink.OnHistoryChanged(uc.workerContext(), dto.HistoryChange{
602 Reasons: reasons,
603 VisitCount: successfulVisits,
604 TitleCount: successfulTitles,
605 ChangedAt: time.Now(),
606 })
607}
608
609func (uc *HistoryRecorderUseCase) historyChangeSink() port.HistoryChangeSink {
610 uc.changeSinkMu.RLock()
611 sink := uc.changeSink
612 uc.changeSinkMu.RUnlock()
613 return normalizeHistoryChangeSink(sink)
614}
615
616// persistHistory writes a history record to the database.
617// Called from the background worker goroutine.
618func (uc *HistoryRecorderUseCase) persistHistory(ctx context.Context, record historyRecord) int {
619 log := logging.FromContext(ctx)
620
621 existing, err := uc.historyRepo.FindByURL(ctx, record.url)
622 if err != nil {
623 log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history")
624 return 0
625 }
626
627 delta := max(1, record.visits)
628 if existing != nil {
629 if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
630 if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
631 log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta")
632 return 0
633 }
634 return delta
635 }
636 written := 0
637 for i := 0; i < delta; i++ {
638 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
639 log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count")
640 return written
641 }
642 written++
643 }
644 return written
645 }
646
647 entry := entity.NewHistoryEntry(record.url, "")
648 entry.VisitCount = int64(delta)
649 if err := uc.historyRepo.Save(ctx, entry); err != nil {
650 log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history")
651 return 0
652 }
653 return delta
654}
655
656// persistTitleUpdate writes a title update to the database.
657// Called from the background worker goroutine.
658func (uc *HistoryRecorderUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) titlePersistenceResult {
659 log := logging.FromContext(ctx)
660
661 if title == "" {
662 return titlePersistenceDiscard
663 }
664
665 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
666 if err != nil {
667 log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update")
668 return titlePersistenceRetry
669 }
670 if entry == nil {
671 return titlePersistenceDiscard
672 }
673
674 entry.Title = title
675 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
676 if err := updater.UpdateMetadata(ctx, entry); err != nil {
677 log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata")
678 return titlePersistenceRetry
679 }
680 return titlePersistenceSaved
681 }
682 if err := uc.historyRepo.Save(ctx, entry); err != nil {
683 log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title")
684 return titlePersistenceRetry
685 }
686 return titlePersistenceSaved
687}
688
689func isRecordableHistoryURL(raw string) bool {
690 parsed, err := url.Parse(strings.TrimSpace(raw))
691 if err != nil {
692 return false
693 }
694 if strings.TrimSpace(parsed.Host) == "" {
695 return false
696 }
697 switch strings.ToLower(parsed.Scheme) {
698 case "http", "https":
699 return true
700 default:
701 return false
702 }
703}
704
705func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
706 raw = strings.TrimSpace(raw)
707 if raw == "" {
708 return ""
709 }
710
711 parsed, err := url.Parse(raw)
712 if err != nil {
713 return strings.TrimSuffix(raw, "/")
714 }
715
716 parsed.Scheme = strings.ToLower(parsed.Scheme)
717 parsed.Host = canonicalHistoryHost(parsed)
718 parsed.User = nil
719 parsed.Fragment = ""
720 parsed.Path = normalizePathForHistory(parsed.Path)
721
722 query := parsed.Query()
723 if opts.StripTrackingParams {
724 for key := range query {
725 if isTrackingQueryParam(key) {
726 query.Del(key)
727 }
728 }
729 }
730 parsed.RawQuery = query.Encode()
731
732 return parsed.String()
733}
734
735func canonicalHistoryHost(parsed *url.URL) string {
736 host := strings.ToLower(parsed.Hostname())
737 portValue := parsed.Port()
738 if portValue == "" || (parsed.Scheme == "http" && portValue == "80") || (parsed.Scheme == "https" && portValue == "443") {
739 if strings.Contains(host, ":") {
740 return "[" + host + "]"
741 }
742 return host
743 }
744 return net.JoinHostPort(host, portValue)
745}
746
747func normalizePathForHistory(path string) string {
748 if path == "/" {
749 return ""
750 }
751 if strings.HasSuffix(path, "/") {
752 return strings.TrimSuffix(path, "/")
753 }
754 return path
755}
756
757func isTrackingQueryParam(key string) bool {
758 key = strings.ToLower(strings.TrimSpace(key))
759 if key == "" {
760 return false
761 }
762 if strings.HasPrefix(key, "utm_") {
763 return true
764 }
765 switch key {
766 case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
767 return true
768 }
769 return false
770}
771
772func isHashOnlyTransition(previous, current string) bool {
773 if previous == "" || current == "" || previous == current {
774 return false
775 }
776
777 prevParsed, prevErr := url.Parse(previous)
778 currParsed, currErr := url.Parse(current)
779 if prevErr != nil || currErr != nil {
780 return false
781 }
782
783 if prevParsed.Fragment == currParsed.Fragment {
784 return false
785 }
786
787 opts := historyCanonicalizationOptions{StripTrackingParams: stripTrackingParamsForHistoryDedup}
788 prevCanonical := canonicalizeURLForHistory(previous, opts)
789 currCanonical := canonicalizeURLForHistory(current, opts)
790 return prevCanonical != "" && prevCanonical == currCanonical
791}
792
793func normalizePaneID(paneID string) string {
794 if strings.TrimSpace(paneID) == "" {
795 return "__default__"
796 }
797 return paneID
798}