feat(ui): add dmenu favicon support and use dumber browse command
- commit
- 3145582437b2ddf41489618ec4f70879d8f23ebe
- author
- bnema · · 2025-12-18 19:18 UTC
- parent
- cc61e77
- signature
- unsigned
4 files · +99 -48
M internal/application/usecase/navigate.go +21 -3
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "strings" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -13,9 +15,10 @@ import ( | |||
| 13 | 15 | ||
| 14 | 16 | // NavigateUseCase handles URL navigation with history recording and zoom application. | |
| 15 | 17 | type NavigateUseCase struct { | |
| 16 | - | historyRepo repository.HistoryRepository | |
| 17 | - | zoomRepo repository.ZoomRepository | |
| 18 | - | defaultZoom float64 | |
| 18 | + | historyRepo repository.HistoryRepository | |
| 19 | + | zoomRepo repository.ZoomRepository | |
| 20 | + | defaultZoom float64 | |
| 21 | + | recentVisits sync.Map // key: url, value: time.Time - for deduplication | |
| 19 | 22 | } | |
| 20 | 23 | ||
| 21 | 24 | // NewNavigateUseCase creates a new navigation use case. | |
| @@ -71,6 +74,11 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N | |||
| 71 | 74 | }, nil | |
| 72 | 75 | } | |
| 73 | 76 | ||
| 77 | + | // historyDeduplicationWindow is the time window for deduplicating history visits. | |
| 78 | + | // Visits to the same URL within this window count as a single visit. | |
| 79 | + | // This prevents inflation from redirects and rapid navigation. | |
| 80 | + | const historyDeduplicationWindow = 2 * time.Second | |
| 81 | + | ||
| 74 | 82 | // RecordHistory saves or updates the history entry. | |
| 75 | 83 | // Should be called on LoadCommitted when URI is guaranteed correct. | |
| 76 | 84 | func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | |
| @@ -79,6 +87,16 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | |||
| 79 | 87 | // Normalize URL to avoid duplicates (e.g., github.com vs github.com/) | |
| 80 | 88 | url = normalizeURLForHistory(url) | |
| 81 | 89 | ||
| 90 | + | // Time-based deduplication: skip if same URL was recorded recently | |
| 91 | + | now := time.Now() | |
| 92 | + | if lastTime, ok := uc.recentVisits.Load(url); ok { | |
| 93 | + | if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow { | |
| 94 | + | log.Debug().Str("url", url).Msg("skipping duplicate history record within dedup window") | |
| 95 | + | return | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | uc.recentVisits.Store(url, now) | |
| 99 | + | ||
| 82 | 100 | // Check if entry exists | |
| 83 | 101 | existing, err := uc.historyRepo.FindByURL(ctx, url) | |
| 84 | 102 | if err != nil { | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | + | "sync" | |||
| 8 | + | "time" | |||
| 7 | 9 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -13,9 +15,10 @@ import ( | |||||
| 13 | 15 | ||||
| 14 | // NavigateUseCase handles URL navigation with history recording and zoom application. | 16 | // NavigateUseCase handles URL navigation with history recording and zoom application. | ||
| 15 | type NavigateUseCase struct { | 17 | type NavigateUseCase struct { | ||
| 16 | - | historyRepo repository.HistoryRepository | 18 | + | historyRepo repository.HistoryRepository |
| 17 | - | zoomRepo repository.ZoomRepository | 19 | + | zoomRepo repository.ZoomRepository |
| 18 | - | defaultZoom float64 | 20 | + | defaultZoom float64 |
| 21 | + | recentVisits sync.Map // key: url, value: time.Time - for deduplication | |||
| 19 | } | 22 | } | ||
| 20 | 23 | ||||
| 21 | // NewNavigateUseCase creates a new navigation use case. | 24 | // NewNavigateUseCase creates a new navigation use case. | ||
| @@ -71,6 +74,11 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N | |||||
| 71 | }, nil | 74 | }, nil | ||
| 72 | } | 75 | } | ||
| 73 | 76 | ||||
| 77 | + | // historyDeduplicationWindow is the time window for deduplicating history visits. | |||
| 78 | + | // Visits to the same URL within this window count as a single visit. | |||
| 79 | + | // This prevents inflation from redirects and rapid navigation. | |||
| 80 | + | const historyDeduplicationWindow = 2 * time.Second | |||
| 81 | + | ||||
| 74 | // RecordHistory saves or updates the history entry. | 82 | // RecordHistory saves or updates the history entry. | ||
| 75 | // Should be called on LoadCommitted when URI is guaranteed correct. | 83 | // Should be called on LoadCommitted when URI is guaranteed correct. | ||
| 76 | func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | 84 | func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | ||
| @@ -79,6 +87,16 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | |||||
| 79 | // Normalize URL to avoid duplicates (e.g., github.com vs github.com/) | 87 | // Normalize URL to avoid duplicates (e.g., github.com vs github.com/) | ||
| 80 | url = normalizeURLForHistory(url) | 88 | url = normalizeURLForHistory(url) | ||
| 81 | 89 | ||||
| 90 | + | // Time-based deduplication: skip if same URL was recorded recently | |||
| 91 | + | now := time.Now() | |||
| 92 | + | if lastTime, ok := uc.recentVisits.Load(url); ok { | |||
| 93 | + | if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow { | |||
| 94 | + | log.Debug().Str("url", url).Msg("skipping duplicate history record within dedup window") | |||
| 95 | + | return | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | uc.recentVisits.Store(url, now) | |||
| 99 | + | ||||
| 82 | // Check if entry exists | 100 | // Check if entry exists | ||
| 83 | existing, err := uc.historyRepo.FindByURL(ctx, url) | 101 | existing, err := uc.historyRepo.FindByURL(ctx, url) | ||
| 84 | if err != nil { | 102 | if err != nil { | ||
M internal/cli/cmd/dmenu.go +36 -4
| @@ -6,12 +6,14 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "os/exec" | |
| 9 | + | "path/filepath" | |
| 9 | 10 | "strings" | |
| 10 | 11 | ||
| 11 | 12 | tea "github.com/charmbracelet/bubbletea" | |
| 12 | 13 | "github.com/spf13/cobra" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/dumber/internal/cli/model" | |
| 16 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 15 | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| @@ -75,6 +77,25 @@ func getDmenuConfig() config.DmenuConfig { | |||
| 75 | 77 | return cfg | |
| 76 | 78 | } | |
| 77 | 79 | ||
| 80 | + | // getFaviconPath returns the path to a cached favicon for the given URL. | |
| 81 | + | // Returns empty string if the favicon doesn't exist in cache. | |
| 82 | + | func getFaviconPath(rawURL string) string { | |
| 83 | + | cacheDir, err := config.GetFaviconCacheDir() | |
| 84 | + | if err != nil { | |
| 85 | + | return "" | |
| 86 | + | } | |
| 87 | + | domain := domainurl.ExtractDomain(rawURL) | |
| 88 | + | if domain == "" { | |
| 89 | + | return "" | |
| 90 | + | } | |
| 91 | + | filename := domainurl.SanitizeDomainForFilename(domain) | |
| 92 | + | path := filepath.Join(cacheDir, filename) | |
| 93 | + | if _, err := os.Stat(path); err == nil { | |
| 94 | + | return path | |
| 95 | + | } | |
| 96 | + | return "" | |
| 97 | + | } | |
| 98 | + | ||
| 78 | 99 | // runDmenuPipe outputs history entries for launcher consumption. | |
| 79 | 100 | func runDmenuPipe() error { | |
| 80 | 101 | app := GetApp() | |
| @@ -113,7 +134,13 @@ func runDmenuPipe() error { | |||
| 113 | 134 | ||
| 114 | 135 | // Output with URL as the actual value (for selection) | |
| 115 | 136 | // Using tab separator so rofi can use -d '\t' | |
| 116 | - | fmt.Printf("%s\t%s\n", parts[0], entry.URL) | |
| 137 | + | // Format: EntryName\0icon\x1f/path/to/favicon\tURL (rofi/fuzzel compatible) | |
| 138 | + | faviconPath := getFaviconPath(entry.URL) | |
| 139 | + | if faviconPath != "" { | |
| 140 | + | fmt.Printf("%s\x00icon\x1f%s\t%s\n", parts[0], faviconPath, entry.URL) | |
| 141 | + | } else { | |
| 142 | + | fmt.Printf("%s\t%s\n", parts[0], entry.URL) | |
| 143 | + | } | |
| 117 | 144 | } | |
| 118 | 145 | ||
| 119 | 146 | return nil | |
| @@ -168,10 +195,15 @@ func runDmenuInteractive() error { | |||
| 168 | 195 | return nil | |
| 169 | 196 | } | |
| 170 | 197 | ||
| 171 | - | // openInBrowser opens a URL in the default browser. | |
| 198 | + | // openInBrowser opens a URL in dumber browser. | |
| 172 | 199 | func openInBrowser(url string) error { | |
| 173 | - | // Try xdg-open first (Linux) | |
| 174 | - | cmd := newCommand("xdg-open", url) | |
| 200 | + | // Use the current executable to open in dumber | |
| 201 | + | executable, err := os.Executable() | |
| 202 | + | if err != nil { | |
| 203 | + | // Fallback to searching PATH | |
| 204 | + | executable = "dumber" | |
| 205 | + | } | |
| 206 | + | cmd := newCommand(executable, "browse", url) | |
| 175 | 207 | return cmd.Start() | |
| 176 | 208 | } | |
| 177 | 209 | ||
| @@ -6,12 +6,14 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "os/exec" | 8 | "os/exec" | ||
| 9 | + | "path/filepath" | |||
| 9 | "strings" | 10 | "strings" | ||
| 10 | 11 | ||||
| 11 | tea "github.com/charmbracelet/bubbletea" | 12 | tea "github.com/charmbracelet/bubbletea" | ||
| 12 | "github.com/spf13/cobra" | 13 | "github.com/spf13/cobra" | ||
| 13 | 14 | ||||
| 14 | "github.com/bnema/dumber/internal/cli/model" | 15 | "github.com/bnema/dumber/internal/cli/model" | ||
| 16 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 15 | "github.com/bnema/dumber/internal/infrastructure/config" | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 16 | ) | 18 | ) | ||
| 17 | 19 | ||||
| @@ -75,6 +77,25 @@ func getDmenuConfig() config.DmenuConfig { | |||||
| 75 | return cfg | 77 | return cfg | ||
| 76 | } | 78 | } | ||
| 77 | 79 | ||||
| 80 | + | // getFaviconPath returns the path to a cached favicon for the given URL. | |||
| 81 | + | // Returns empty string if the favicon doesn't exist in cache. | |||
| 82 | + | func getFaviconPath(rawURL string) string { | |||
| 83 | + | cacheDir, err := config.GetFaviconCacheDir() | |||
| 84 | + | if err != nil { | |||
| 85 | + | return "" | |||
| 86 | + | } | |||
| 87 | + | domain := domainurl.ExtractDomain(rawURL) | |||
| 88 | + | if domain == "" { | |||
| 89 | + | return "" | |||
| 90 | + | } | |||
| 91 | + | filename := domainurl.SanitizeDomainForFilename(domain) | |||
| 92 | + | path := filepath.Join(cacheDir, filename) | |||
| 93 | + | if _, err := os.Stat(path); err == nil { | |||
| 94 | + | return path | |||
| 95 | + | } | |||
| 96 | + | return "" | |||
| 97 | + | } | |||
| 98 | + | ||||
| 78 | // runDmenuPipe outputs history entries for launcher consumption. | 99 | // runDmenuPipe outputs history entries for launcher consumption. | ||
| 79 | func runDmenuPipe() error { | 100 | func runDmenuPipe() error { | ||
| 80 | app := GetApp() | 101 | app := GetApp() | ||
| @@ -113,7 +134,13 @@ func runDmenuPipe() error { | |||||
| 113 | 134 | ||||
| 114 | // Output with URL as the actual value (for selection) | 135 | // Output with URL as the actual value (for selection) | ||
| 115 | // Using tab separator so rofi can use -d '\t' | 136 | // Using tab separator so rofi can use -d '\t' | ||
| 116 | - | fmt.Printf("%s\t%s\n", parts[0], entry.URL) | 137 | + | // Format: EntryName\0icon\x1f/path/to/favicon\tURL (rofi/fuzzel compatible) |
| 138 | + | faviconPath := getFaviconPath(entry.URL) | |||
| 139 | + | if faviconPath != "" { | |||
| 140 | + | fmt.Printf("%s\x00icon\x1f%s\t%s\n", parts[0], faviconPath, entry.URL) | |||
| 141 | + | } else { | |||
| 142 | + | fmt.Printf("%s\t%s\n", parts[0], entry.URL) | |||
| 143 | + | } | |||
| 117 | } | 144 | } | ||
| 118 | 145 | ||||
| 119 | return nil | 146 | return nil | ||
| @@ -168,10 +195,15 @@ func runDmenuInteractive() error { | |||||
| 168 | return nil | 195 | return nil | ||
| 169 | } | 196 | } | ||
| 170 | 197 | ||||
| 171 | - | // openInBrowser opens a URL in the default browser. | 198 | + | // openInBrowser opens a URL in dumber browser. |
| 172 | func openInBrowser(url string) error { | 199 | func openInBrowser(url string) error { | ||
| 173 | - | // Try xdg-open first (Linux) | 200 | + | // Use the current executable to open in dumber |
| 174 | - | cmd := newCommand("xdg-open", url) | 201 | + | executable, err := os.Executable() |
| 202 | + | if err != nil { | |||
| 203 | + | // Fallback to searching PATH | |||
| 204 | + | executable = "dumber" | |||
| 205 | + | } | |||
| 206 | + | cmd := newCommand(executable, "browse", url) | |||
| 175 | return cmd.Start() | 207 | return cmd.Start() | ||
| 176 | } | 208 | } | ||
| 177 | 209 | ||||
M internal/domain/url/normalize.go +35 -1
| @@ -1,7 +1,10 @@ | |||
| 1 | 1 | // Package url provides URL manipulation utilities for the browser. | |
| 2 | 2 | package url | |
| 3 | 3 | ||
| 4 | - | import "strings" | |
| 4 | + | import ( | |
| 5 | + | "net/url" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 5 | 8 | ||
| 6 | 9 | // Normalize adds https:// prefix if missing for URL-like inputs. | |
| 7 | 10 | // Returns the input unchanged if it already has a scheme or doesn't look like a URL. | |
| @@ -41,3 +44,34 @@ func LooksLikeURL(input string) bool { | |||
| 41 | 44 | // Contains a dot and no spaces = likely a URL | |
| 42 | 45 | return strings.Contains(input, ".") && !strings.Contains(input, " ") | |
| 43 | 46 | } | |
| 47 | + | ||
| 48 | + | // ExtractDomain extracts the normalized domain (host) from a URL string. | |
| 49 | + | // Normalizes by stripping "www." prefix so youtube.com and www.youtube.com | |
| 50 | + | // resolve to the same value. | |
| 51 | + | func ExtractDomain(rawURL string) string { | |
| 52 | + | if rawURL == "" { | |
| 53 | + | return "" | |
| 54 | + | } | |
| 55 | + | parsed, err := url.Parse(rawURL) | |
| 56 | + | if err != nil || parsed.Host == "" { | |
| 57 | + | return "" | |
| 58 | + | } | |
| 59 | + | return strings.TrimPrefix(parsed.Host, "www.") | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension. | |
| 63 | + | // Replaces unsafe filesystem characters with underscores. | |
| 64 | + | func SanitizeDomainForFilename(domain string) string { | |
| 65 | + | replacer := strings.NewReplacer( | |
| 66 | + | ":", "_", | |
| 67 | + | "/", "_", | |
| 68 | + | "\\", "_", | |
| 69 | + | "*", "_", | |
| 70 | + | "?", "_", | |
| 71 | + | "\"", "_", | |
| 72 | + | "<", "_", | |
| 73 | + | ">", "_", | |
| 74 | + | "|", "_", | |
| 75 | + | ) | |
| 76 | + | return replacer.Replace(domain) + ".ico" | |
| 77 | + | } | |
| @@ -1,7 +1,10 @@ | |||||
| 1 | // Package url provides URL manipulation utilities for the browser. | 1 | // Package url provides URL manipulation utilities for the browser. | ||
| 2 | package url | 2 | package url | ||
| 3 | 3 | ||||
| 4 | - | import "strings" | 4 | + | import ( |
| 5 | + | "net/url" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 5 | 8 | ||||
| 6 | // Normalize adds https:// prefix if missing for URL-like inputs. | 9 | // Normalize adds https:// prefix if missing for URL-like inputs. | ||
| 7 | // Returns the input unchanged if it already has a scheme or doesn't look like a URL. | 10 | // Returns the input unchanged if it already has a scheme or doesn't look like a URL. | ||
| @@ -41,3 +44,34 @@ func LooksLikeURL(input string) bool { | |||||
| 41 | // Contains a dot and no spaces = likely a URL | 44 | // Contains a dot and no spaces = likely a URL | ||
| 42 | return strings.Contains(input, ".") && !strings.Contains(input, " ") | 45 | return strings.Contains(input, ".") && !strings.Contains(input, " ") | ||
| 43 | } | 46 | } | ||
| 47 | + | ||||
| 48 | + | // ExtractDomain extracts the normalized domain (host) from a URL string. | |||
| 49 | + | // Normalizes by stripping "www." prefix so youtube.com and www.youtube.com | |||
| 50 | + | // resolve to the same value. | |||
| 51 | + | func ExtractDomain(rawURL string) string { | |||
| 52 | + | if rawURL == "" { | |||
| 53 | + | return "" | |||
| 54 | + | } | |||
| 55 | + | parsed, err := url.Parse(rawURL) | |||
| 56 | + | if err != nil || parsed.Host == "" { | |||
| 57 | + | return "" | |||
| 58 | + | } | |||
| 59 | + | return strings.TrimPrefix(parsed.Host, "www.") | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension. | |||
| 63 | + | // Replaces unsafe filesystem characters with underscores. | |||
| 64 | + | func SanitizeDomainForFilename(domain string) string { | |||
| 65 | + | replacer := strings.NewReplacer( | |||
| 66 | + | ":", "_", | |||
| 67 | + | "/", "_", | |||
| 68 | + | "\\", "_", | |||
| 69 | + | "*", "_", | |||
| 70 | + | "?", "_", | |||
| 71 | + | "\"", "_", | |||
| 72 | + | "<", "_", | |||
| 73 | + | ">", "_", | |||
| 74 | + | "|", "_", | |||
| 75 | + | ) | |||
| 76 | + | return replacer.Replace(domain) + ".ico" | |||
| 77 | + | } | |||
M internal/ui/cache/favicon_cache.go +7 -40
| @@ -9,10 +9,10 @@ import ( | |||
| 9 | 9 | "net/url" | |
| 10 | 10 | "os" | |
| 11 | 11 | "path/filepath" | |
| 12 | - | "strings" | |
| 13 | 12 | "sync" | |
| 14 | 13 | "time" | |
| 15 | 14 | ||
| 15 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 16 | 16 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 17 | 17 | "github.com/bnema/dumber/internal/logging" | |
| 18 | 18 | "github.com/bnema/puregotk-webkit/webkit" | |
| @@ -78,23 +78,6 @@ func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache { | |||
| 78 | 78 | return fc | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | - | // extractDomain extracts the normalized domain (host) from a URL string. | |
| 82 | - | // Normalizes by stripping "www." prefix so youtube.com and www.youtube.com | |
| 83 | - | // resolve to the same cache key. | |
| 84 | - | func extractDomain(rawURL string) string { | |
| 85 | - | if rawURL == "" { | |
| 86 | - | return "" | |
| 87 | - | } | |
| 88 | - | parsed, err := url.Parse(rawURL) | |
| 89 | - | if err != nil { | |
| 90 | - | return "" | |
| 91 | - | } | |
| 92 | - | host := parsed.Host | |
| 93 | - | // Normalize: strip www. prefix | |
| 94 | - | host = strings.TrimPrefix(host, "www.") | |
| 95 | - | return host | |
| 96 | - | } | |
| 97 | - | ||
| 98 | 81 | // Set stores a favicon texture for the given domain. | |
| 99 | 82 | func (fc *FaviconCache) Set(domain string, texture *gdk.Texture) { | |
| 100 | 83 | if domain == "" || texture == nil { | |
| @@ -107,7 +90,7 @@ func (fc *FaviconCache) Set(domain string, texture *gdk.Texture) { | |||
| 107 | 90 | ||
| 108 | 91 | // SetByURL stores a favicon texture for the domain extracted from the URL. | |
| 109 | 92 | func (fc *FaviconCache) SetByURL(pageURL string, texture *gdk.Texture) { | |
| 110 | - | domain := extractDomain(pageURL) | |
| 93 | + | domain := domainurl.ExtractDomain(pageURL) | |
| 111 | 94 | fc.Set(domain, texture) | |
| 112 | 95 | } | |
| 113 | 96 | ||
| @@ -126,7 +109,7 @@ func (fc *FaviconCache) Get(domain string) *gdk.Texture { | |||
| 126 | 109 | // GetByURL retrieves a favicon texture by extracting the domain from the URL. | |
| 127 | 110 | // Returns nil if not found in memory cache. | |
| 128 | 111 | func (fc *FaviconCache) GetByURL(pageURL string) *gdk.Texture { | |
| 129 | - | domain := extractDomain(pageURL) | |
| 112 | + | domain := domainurl.ExtractDomain(pageURL) | |
| 130 | 113 | return fc.Get(domain) | |
| 131 | 114 | } | |
| 132 | 115 | ||
| @@ -134,7 +117,7 @@ func (fc *FaviconCache) GetByURL(pageURL string) *gdk.Texture { | |||
| 134 | 117 | // Returns nil if not found. Does not fetch from external sources. | |
| 135 | 118 | // If found on disk, also populates memory cache. | |
| 136 | 119 | func (fc *FaviconCache) GetFromCacheByURL(pageURL string) *gdk.Texture { | |
| 137 | - | domain := extractDomain(pageURL) | |
| 120 | + | domain := domainurl.ExtractDomain(pageURL) | |
| 138 | 121 | if domain == "" { | |
| 139 | 122 | return nil | |
| 140 | 123 | } | |
| @@ -163,7 +146,7 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback | |||
| 163 | 146 | } | |
| 164 | 147 | ||
| 165 | 148 | log := logging.FromContext(ctx) | |
| 166 | - | domain := extractDomain(pageURL) | |
| 149 | + | domain := domainurl.ExtractDomain(pageURL) | |
| 167 | 150 | ||
| 168 | 151 | // Check in-memory cache first | |
| 169 | 152 | if texture := fc.Get(domain); texture != nil { | |
| @@ -315,22 +298,6 @@ func (fc *FaviconCache) Close() { | |||
| 315 | 298 | }) | |
| 316 | 299 | } | |
| 317 | 300 | ||
| 318 | - | // sanitizeDomainForFilename converts a domain to a safe filename. | |
| 319 | - | func sanitizeDomainForFilename(domain string) string { | |
| 320 | - | replacer := strings.NewReplacer( | |
| 321 | - | ":", "_", | |
| 322 | - | "/", "_", | |
| 323 | - | "\\", "_", | |
| 324 | - | "*", "_", | |
| 325 | - | "?", "_", | |
| 326 | - | "\"", "_", | |
| 327 | - | "<", "_", | |
| 328 | - | ">", "_", | |
| 329 | - | "|", "_", | |
| 330 | - | ) | |
| 331 | - | return replacer.Replace(domain) + ".ico" | |
| 332 | - | } | |
| 333 | - | ||
| 334 | 301 | // loadFromDisk attempts to load a favicon from disk cache. | |
| 335 | 302 | // Returns nil if not found or on error. | |
| 336 | 303 | func (fc *FaviconCache) loadFromDisk(domain string) *gdk.Texture { | |
| @@ -338,7 +305,7 @@ func (fc *FaviconCache) loadFromDisk(domain string) *gdk.Texture { | |||
| 338 | 305 | return nil | |
| 339 | 306 | } | |
| 340 | 307 | ||
| 341 | - | filename := sanitizeDomainForFilename(domain) | |
| 308 | + | filename := domainurl.SanitizeDomainForFilename(domain) | |
| 342 | 309 | path := filepath.Join(fc.diskCacheDir, filename) | |
| 343 | 310 | ||
| 344 | 311 | //nolint:gosec // path is constructed from sanitized domain, not user input | |
| @@ -375,7 +342,7 @@ func (fc *FaviconCache) writeToDisk(domain string, data []byte) { | |||
| 375 | 342 | return | |
| 376 | 343 | } | |
| 377 | 344 | ||
| 378 | - | filename := sanitizeDomainForFilename(domain) | |
| 345 | + | filename := domainurl.SanitizeDomainForFilename(domain) | |
| 379 | 346 | finalPath := filepath.Join(fc.diskCacheDir, filename) | |
| 380 | 347 | tempPath := finalPath + ".tmp" | |
| 381 | 348 | ||
| @@ -9,10 +9,10 @@ import ( | |||||
| 9 | "net/url" | 9 | "net/url" | ||
| 10 | "os" | 10 | "os" | ||
| 11 | "path/filepath" | 11 | "path/filepath" | ||
| 12 | - | "strings" | |||
| 13 | "sync" | 12 | "sync" | ||
| 14 | "time" | 13 | "time" | ||
| 15 | 14 | ||||
| 15 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 16 | "github.com/bnema/dumber/internal/infrastructure/config" | 16 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 17 | "github.com/bnema/dumber/internal/logging" | 17 | "github.com/bnema/dumber/internal/logging" | ||
| 18 | "github.com/bnema/puregotk-webkit/webkit" | 18 | "github.com/bnema/puregotk-webkit/webkit" | ||
| @@ -78,23 +78,6 @@ func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache { | |||||
| 78 | return fc | 78 | return fc | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | - | // extractDomain extracts the normalized domain (host) from a URL string. | |||
| 82 | - | // Normalizes by stripping "www." prefix so youtube.com and www.youtube.com | |||
| 83 | - | // resolve to the same cache key. | |||
| 84 | - | func extractDomain(rawURL string) string { | |||
| 85 | - | if rawURL == "" { | |||
| 86 | - | return "" | |||
| 87 | - | } | |||
| 88 | - | parsed, err := url.Parse(rawURL) | |||
| 89 | - | if err != nil { | |||
| 90 | - | return "" | |||
| 91 | - | } | |||
| 92 | - | host := parsed.Host | |||
| 93 | - | // Normalize: strip www. prefix | |||
| 94 | - | host = strings.TrimPrefix(host, "www.") | |||
| 95 | - | return host | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | // Set stores a favicon texture for the given domain. | 81 | // Set stores a favicon texture for the given domain. | ||
| 99 | func (fc *FaviconCache) Set(domain string, texture *gdk.Texture) { | 82 | func (fc *FaviconCache) Set(domain string, texture *gdk.Texture) { | ||
| 100 | if domain == "" || texture == nil { | 83 | if domain == "" || texture == nil { | ||
| @@ -107,7 +90,7 @@ func (fc *FaviconCache) Set(domain string, texture *gdk.Texture) { | |||||
| 107 | 90 | ||||
| 108 | // SetByURL stores a favicon texture for the domain extracted from the URL. | 91 | // SetByURL stores a favicon texture for the domain extracted from the URL. | ||
| 109 | func (fc *FaviconCache) SetByURL(pageURL string, texture *gdk.Texture) { | 92 | func (fc *FaviconCache) SetByURL(pageURL string, texture *gdk.Texture) { | ||
| 110 | - | domain := extractDomain(pageURL) | 93 | + | domain := domainurl.ExtractDomain(pageURL) |
| 111 | fc.Set(domain, texture) | 94 | fc.Set(domain, texture) | ||
| 112 | } | 95 | } | ||
| 113 | 96 | ||||
| @@ -126,7 +109,7 @@ func (fc *FaviconCache) Get(domain string) *gdk.Texture { | |||||
| 126 | // GetByURL retrieves a favicon texture by extracting the domain from the URL. | 109 | // GetByURL retrieves a favicon texture by extracting the domain from the URL. | ||
| 127 | // Returns nil if not found in memory cache. | 110 | // Returns nil if not found in memory cache. | ||
| 128 | func (fc *FaviconCache) GetByURL(pageURL string) *gdk.Texture { | 111 | func (fc *FaviconCache) GetByURL(pageURL string) *gdk.Texture { | ||
| 129 | - | domain := extractDomain(pageURL) | 112 | + | domain := domainurl.ExtractDomain(pageURL) |
| 130 | return fc.Get(domain) | 113 | return fc.Get(domain) | ||
| 131 | } | 114 | } | ||
| 132 | 115 | ||||
| @@ -134,7 +117,7 @@ func (fc *FaviconCache) GetByURL(pageURL string) *gdk.Texture { | |||||
| 134 | // Returns nil if not found. Does not fetch from external sources. | 117 | // Returns nil if not found. Does not fetch from external sources. | ||
| 135 | // If found on disk, also populates memory cache. | 118 | // If found on disk, also populates memory cache. | ||
| 136 | func (fc *FaviconCache) GetFromCacheByURL(pageURL string) *gdk.Texture { | 119 | func (fc *FaviconCache) GetFromCacheByURL(pageURL string) *gdk.Texture { | ||
| 137 | - | domain := extractDomain(pageURL) | 120 | + | domain := domainurl.ExtractDomain(pageURL) |
| 138 | if domain == "" { | 121 | if domain == "" { | ||
| 139 | return nil | 122 | return nil | ||
| 140 | } | 123 | } | ||
| @@ -163,7 +146,7 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback | |||||
| 163 | } | 146 | } | ||
| 164 | 147 | ||||
| 165 | log := logging.FromContext(ctx) | 148 | log := logging.FromContext(ctx) | ||
| 166 | - | domain := extractDomain(pageURL) | 149 | + | domain := domainurl.ExtractDomain(pageURL) |
| 167 | 150 | ||||
| 168 | // Check in-memory cache first | 151 | // Check in-memory cache first | ||
| 169 | if texture := fc.Get(domain); texture != nil { | 152 | if texture := fc.Get(domain); texture != nil { | ||
| @@ -315,22 +298,6 @@ func (fc *FaviconCache) Close() { | |||||
| 315 | }) | 298 | }) | ||
| 316 | } | 299 | } | ||
| 317 | 300 | ||||
| 318 | - | // sanitizeDomainForFilename converts a domain to a safe filename. | |||
| 319 | - | func sanitizeDomainForFilename(domain string) string { | |||
| 320 | - | replacer := strings.NewReplacer( | |||
| 321 | - | ":", "_", | |||
| 322 | - | "/", "_", | |||
| 323 | - | "\\", "_", | |||
| 324 | - | "*", "_", | |||
| 325 | - | "?", "_", | |||
| 326 | - | "\"", "_", | |||
| 327 | - | "<", "_", | |||
| 328 | - | ">", "_", | |||
| 329 | - | "|", "_", | |||
| 330 | - | ) | |||
| 331 | - | return replacer.Replace(domain) + ".ico" | |||
| 332 | - | } | |||
| 333 | - | ||||
| 334 | // loadFromDisk attempts to load a favicon from disk cache. | 301 | // loadFromDisk attempts to load a favicon from disk cache. | ||
| 335 | // Returns nil if not found or on error. | 302 | // Returns nil if not found or on error. | ||
| 336 | func (fc *FaviconCache) loadFromDisk(domain string) *gdk.Texture { | 303 | func (fc *FaviconCache) loadFromDisk(domain string) *gdk.Texture { | ||
| @@ -338,7 +305,7 @@ func (fc *FaviconCache) loadFromDisk(domain string) *gdk.Texture { | |||||
| 338 | return nil | 305 | return nil | ||
| 339 | } | 306 | } | ||
| 340 | 307 | ||||
| 341 | - | filename := sanitizeDomainForFilename(domain) | 308 | + | filename := domainurl.SanitizeDomainForFilename(domain) |
| 342 | path := filepath.Join(fc.diskCacheDir, filename) | 309 | path := filepath.Join(fc.diskCacheDir, filename) | ||
| 343 | 310 | ||||
| 344 | //nolint:gosec // path is constructed from sanitized domain, not user input | 311 | //nolint:gosec // path is constructed from sanitized domain, not user input | ||
| @@ -375,7 +342,7 @@ func (fc *FaviconCache) writeToDisk(domain string, data []byte) { | |||||
| 375 | return | 342 | return | ||
| 376 | } | 343 | } | ||
| 377 | 344 | ||||
| 378 | - | filename := sanitizeDomainForFilename(domain) | 345 | + | filename := domainurl.SanitizeDomainForFilename(domain) |
| 379 | finalPath := filepath.Join(fc.diskCacheDir, filename) | 346 | finalPath := filepath.Join(fc.diskCacheDir, filename) | ||
| 380 | tempPath := finalPath + ".tmp" | 347 | tempPath := finalPath + ".tmp" | ||
| 381 | 348 | ||||