feat(favicon): implement comprehensive favicon caching system for dmenu
- Add complete ICO file support using go-ico library for Windows icon conversion - Implement smart brightness detection and color inversion for dark theme visibility - Normalize all cached favicons to consistent 32x32 PNG format - Add anti-aliasing preservation during color inversion to prevent edge noise - Support asynchronous favicon downloading with 7-day freshness and 30-day cleanup - Integrate fuzzel icon protocol (\0icon\x1f<path>) for native icon display - Extract favicon URLs from webpage metadata during navigation - Remove inconsistent system icon fallbacks for uniform sizing - Store favicon cache in XDG-compliant data directories
- commit
- a3da3ff834d6ba84423a21b53dc38c64ce37e81f
- author
- bamen · · 2025-09-12 04:22 UTC
- parent
- b18c1b0
- signature
- unsigned
22 files · +1058 -234
M CHANGELOG.md +11 -0
| @@ -27,6 +27,17 @@ All notable changes to this project will be documented in this file. | |||
| 27 | 27 | - Improved box-sizing model to prevent layout issues | |
| 28 | 28 | - Added conditional separator display (hidden when no search results) | |
| 29 | 29 | - Omnibox now scales proportionally with webpage content across all zoom levels | |
| 30 | + | - Dmenu history favicon display: | |
| 31 | + | - Complete favicon caching system for fuzzel/dmenu integration with real website icons | |
| 32 | + | - ICO file support using go-ico library for perfect Windows icon conversion to PNG | |
| 33 | + | - Smart brightness detection and color inversion for dark theme visibility | |
| 34 | + | - All cached favicons normalized to consistent 32x32 PNG format for uniform display | |
| 35 | + | - Anti-aliasing preservation during color inversion to prevent edge noise and pixelation | |
| 36 | + | - Asynchronous favicon downloading and caching with 7-day freshness and 30-day cleanup | |
| 37 | + | - Fuzzel icon protocol integration (`\0icon\x1f<path>`) for native icon display | |
| 38 | + | - Favicon URLs extracted from webpage metadata and stored in database during navigation | |
| 39 | + | - Fallback system removed to ensure consistent sizing - shows real favicons or no icon | |
| 40 | + | - XDG Base Directory compliance for favicon cache storage | |
| 30 | 41 | ||
| 31 | 42 | ## [0.4.0] - 2025-09-10 | |
| 32 | 43 | ||
| @@ -27,6 +27,17 @@ All notable changes to this project will be documented in this file. | |||||
| 27 | - Improved box-sizing model to prevent layout issues | 27 | - Improved box-sizing model to prevent layout issues | ||
| 28 | - Added conditional separator display (hidden when no search results) | 28 | - Added conditional separator display (hidden when no search results) | ||
| 29 | - Omnibox now scales proportionally with webpage content across all zoom levels | 29 | - Omnibox now scales proportionally with webpage content across all zoom levels | ||
| 30 | + | - Dmenu history favicon display: | |||
| 31 | + | - Complete favicon caching system for fuzzel/dmenu integration with real website icons | |||
| 32 | + | - ICO file support using go-ico library for perfect Windows icon conversion to PNG | |||
| 33 | + | - Smart brightness detection and color inversion for dark theme visibility | |||
| 34 | + | - All cached favicons normalized to consistent 32x32 PNG format for uniform display | |||
| 35 | + | - Anti-aliasing preservation during color inversion to prevent edge noise and pixelation | |||
| 36 | + | - Asynchronous favicon downloading and caching with 7-day freshness and 30-day cleanup | |||
| 37 | + | - Fuzzel icon protocol integration (`\0icon\x1f<path>`) for native icon display | |||
| 38 | + | - Favicon URLs extracted from webpage metadata and stored in database during navigation | |||
| 39 | + | - Fallback system removed to ensure consistent sizing - shows real favicons or no icon | |||
| 40 | + | - XDG Base Directory compliance for favicon cache storage | |||
| 30 | 41 | ||||
| 31 | ## [0.4.0] - 2025-09-10 | 42 | ## [0.4.0] - 2025-09-10 | ||
| 32 | 43 | ||||
M go.mod +2 -0
| @@ -18,6 +18,8 @@ require ( | |||
| 18 | 18 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | |
| 19 | 19 | github.com/rogpeppe/go-internal v1.12.0 // indirect | |
| 20 | 20 | github.com/sagikazarmark/locafero v0.11.0 // indirect | |
| 21 | + | github.com/sergeymakinen/go-bmp v1.0.0 // indirect | |
| 22 | + | github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect | |
| 21 | 23 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | |
| 22 | 24 | github.com/spf13/afero v1.15.0 // indirect | |
| 23 | 25 | github.com/spf13/cast v1.10.0 // indirect | |
| @@ -18,6 +18,8 @@ require ( | |||||
| 18 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | 18 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | ||
| 19 | github.com/rogpeppe/go-internal v1.12.0 // indirect | 19 | github.com/rogpeppe/go-internal v1.12.0 // indirect | ||
| 20 | github.com/sagikazarmark/locafero v0.11.0 // indirect | 20 | github.com/sagikazarmark/locafero v0.11.0 // indirect | ||
| 21 | + | github.com/sergeymakinen/go-bmp v1.0.0 // indirect | |||
| 22 | + | github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect | |||
| 21 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | 23 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | ||
| 22 | github.com/spf13/afero v1.15.0 // indirect | 24 | github.com/spf13/afero v1.15.0 // indirect | ||
| 23 | github.com/spf13/cast v1.10.0 // indirect | 25 | github.com/spf13/cast v1.10.0 // indirect | ||
M go.sum +6 -0 generated file: collapsed
A internal/cache/favicon.go +387 -0
| @@ -0,0 +1,387 @@ | |||
| 1 | + | package cache | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/md5" | |
| 5 | + | "fmt" | |
| 6 | + | "image" | |
| 7 | + | "image/color" | |
| 8 | + | "image/png" | |
| 9 | + | _ "image/jpeg" // Register JPEG decoder | |
| 10 | + | _ "image/gif" // Register GIF decoder | |
| 11 | + | "io" | |
| 12 | + | "log" | |
| 13 | + | "net/http" | |
| 14 | + | "os" | |
| 15 | + | "path/filepath" | |
| 16 | + | "strings" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/bnema/dumber/internal/config" | |
| 20 | + | "github.com/sergeymakinen/go-ico" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // FaviconCache manages favicon caching to local files | |
| 24 | + | type FaviconCache struct { | |
| 25 | + | cacheDir string | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // NewFaviconCache creates a new favicon cache | |
| 29 | + | func NewFaviconCache() (*FaviconCache, error) { | |
| 30 | + | dataDir, err := config.GetDataDir() | |
| 31 | + | if err != nil { | |
| 32 | + | return nil, fmt.Errorf("failed to get data directory: %w", err) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | cacheDir := filepath.Join(dataDir, "favicons") | |
| 36 | + | if err := os.MkdirAll(cacheDir, 0755); err != nil { | |
| 37 | + | return nil, fmt.Errorf("failed to create favicon cache directory: %w", err) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | return &FaviconCache{ | |
| 41 | + | cacheDir: cacheDir, | |
| 42 | + | }, nil | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // GetCachedPath returns the local file path for a favicon URL if cached, empty string otherwise | |
| 46 | + | func (fc *FaviconCache) GetCachedPath(faviconURL string) string { | |
| 47 | + | if faviconURL == "" { | |
| 48 | + | return "" | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | filename := fc.getFilename(faviconURL) | |
| 52 | + | cachedPath := filepath.Join(fc.cacheDir, filename) | |
| 53 | + | ||
| 54 | + | // Check if file exists and is not too old (7 days) | |
| 55 | + | if info, err := os.Stat(cachedPath); err == nil { | |
| 56 | + | if time.Since(info.ModTime()) < 7*24*time.Hour { | |
| 57 | + | return cachedPath | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | return "" | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // CacheAsync downloads and caches a favicon asynchronously | |
| 65 | + | func (fc *FaviconCache) CacheAsync(faviconURL string) { | |
| 66 | + | if faviconURL == "" { | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | go func() { | |
| 71 | + | if err := fc.downloadFavicon(faviconURL); err != nil { | |
| 72 | + | // Silently ignore errors in background download | |
| 73 | + | } | |
| 74 | + | }() | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // getFilename generates a safe filename for a favicon URL - always PNG for fuzzel compatibility | |
| 78 | + | func (fc *FaviconCache) getFilename(faviconURL string) string { | |
| 79 | + | hash := fmt.Sprintf("%x", md5.Sum([]byte(faviconURL))) | |
| 80 | + | return hash + ".png" | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // downloadFavicon downloads a favicon and saves it to cache | |
| 84 | + | func (fc *FaviconCache) downloadFavicon(faviconURL string) error { | |
| 85 | + | filename := fc.getFilename(faviconURL) | |
| 86 | + | cachedPath := filepath.Join(fc.cacheDir, filename) | |
| 87 | + | ||
| 88 | + | // Don't download if already exists and is recent | |
| 89 | + | if info, err := os.Stat(cachedPath); err == nil { | |
| 90 | + | if time.Since(info.ModTime()) < 7*24*time.Hour { | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Create HTTP client with timeout | |
| 96 | + | client := &http.Client{ | |
| 97 | + | Timeout: 10 * time.Second, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | resp, err := client.Get(faviconURL) | |
| 101 | + | if err != nil { | |
| 102 | + | return fmt.Errorf("failed to download favicon: %w", err) | |
| 103 | + | } | |
| 104 | + | defer resp.Body.Close() | |
| 105 | + | ||
| 106 | + | if resp.StatusCode != http.StatusOK { | |
| 107 | + | return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Limit response body size | |
| 111 | + | limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max | |
| 112 | + | ||
| 113 | + | // Download to temporary file first | |
| 114 | + | tempDownloadFile := cachedPath + ".download" | |
| 115 | + | downloadFile, err := os.Create(tempDownloadFile) | |
| 116 | + | if err != nil { | |
| 117 | + | return fmt.Errorf("failed to create download temp file: %w", err) | |
| 118 | + | } | |
| 119 | + | defer downloadFile.Close() | |
| 120 | + | defer os.Remove(tempDownloadFile) | |
| 121 | + | ||
| 122 | + | // Save raw favicon data | |
| 123 | + | if _, err := io.Copy(downloadFile, limitedBody); err != nil { | |
| 124 | + | return fmt.Errorf("failed to save favicon data: %w", err) | |
| 125 | + | } | |
| 126 | + | downloadFile.Close() | |
| 127 | + | ||
| 128 | + | // Detect if it's an ICO file and convert with ffmpeg if needed | |
| 129 | + | if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil { | |
| 130 | + | return fmt.Errorf("failed to convert favicon: %w", err) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | return nil | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // convertToPNG converts a favicon file to PNG format | |
| 137 | + | func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) error { | |
| 138 | + | // Check if it's an ICO file based on URL or content type | |
| 139 | + | if strings.Contains(strings.ToLower(faviconURL), ".ico") { | |
| 140 | + | log.Printf("[favicon] ICO file detected for %s, using go-ico decoder", faviconURL) | |
| 141 | + | return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Try standard Go image decoder for other formats (PNG, JPEG, GIF) | |
| 145 | + | if img, err := fc.tryStandardImageDecode(inputPath); err == nil { | |
| 146 | + | log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL) | |
| 147 | + | ||
| 148 | + | // Apply dark theme improvements | |
| 149 | + | img = invertIfTooDark(img) | |
| 150 | + | ||
| 151 | + | // Save as PNG | |
| 152 | + | return fc.savePNG(img, outputPath) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // Standard decode failed, fallback to go-ico | |
| 156 | + | log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL) | |
| 157 | + | return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // tryStandardImageDecode attempts to decode image with Go standard library | |
| 161 | + | func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, error) { | |
| 162 | + | file, err := os.Open(filePath) | |
| 163 | + | if err != nil { | |
| 164 | + | return nil, err | |
| 165 | + | } | |
| 166 | + | defer file.Close() | |
| 167 | + | ||
| 168 | + | img, _, err := image.Decode(file) | |
| 169 | + | return img, err | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // savePNG saves an image as PNG | |
| 173 | + | func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error { | |
| 174 | + | tempFile := outputPath + ".tmp" | |
| 175 | + | file, err := os.Create(tempFile) | |
| 176 | + | if err != nil { | |
| 177 | + | return fmt.Errorf("failed to create temp file: %w", err) | |
| 178 | + | } | |
| 179 | + | defer file.Close() | |
| 180 | + | ||
| 181 | + | if err := png.Encode(file, img); err != nil { | |
| 182 | + | os.Remove(tempFile) | |
| 183 | + | return fmt.Errorf("failed to encode PNG: %w", err) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | file.Close() | |
| 187 | + | ||
| 188 | + | // Atomic move | |
| 189 | + | if err := os.Rename(tempFile, outputPath); err != nil { | |
| 190 | + | os.Remove(tempFile) | |
| 191 | + | return fmt.Errorf("failed to move PNG to cache: %w", err) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | return nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // convertICOWithGoICO converts ICO files to PNG using go-ico library | |
| 198 | + | func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL string) error { | |
| 199 | + | // Open the ICO file | |
| 200 | + | file, err := os.Open(inputPath) | |
| 201 | + | if err != nil { | |
| 202 | + | return fmt.Errorf("failed to open ICO file: %w", err) | |
| 203 | + | } | |
| 204 | + | defer file.Close() | |
| 205 | + | ||
| 206 | + | // Decode ICO file | |
| 207 | + | img, err := ico.Decode(file) | |
| 208 | + | if err != nil { | |
| 209 | + | return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL) | |
| 213 | + | ||
| 214 | + | // Apply dark theme improvements | |
| 215 | + | img = invertIfTooDark(img) | |
| 216 | + | ||
| 217 | + | // Save as PNG | |
| 218 | + | return fc.savePNG(img, outputPath) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // CleanOld removes cached favicons older than 30 days | |
| 222 | + | func (fc *FaviconCache) CleanOld() error { | |
| 223 | + | entries, err := os.ReadDir(fc.cacheDir) | |
| 224 | + | if err != nil { | |
| 225 | + | return err | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | cutoff := time.Now().Add(-30 * 24 * time.Hour) | |
| 229 | + | ||
| 230 | + | for _, entry := range entries { | |
| 231 | + | if entry.IsDir() { | |
| 232 | + | continue | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | info, err := entry.Info() | |
| 236 | + | if err != nil { | |
| 237 | + | continue | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | if info.ModTime().Before(cutoff) { | |
| 241 | + | os.Remove(filepath.Join(fc.cacheDir, entry.Name())) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | return nil | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // invertIfTooDark inverts icons that are too dark for dark theme visibility | |
| 249 | + | // Only inverts truly monochrome dark icons, preserves colorful icons | |
| 250 | + | func invertIfTooDark(img image.Image) image.Image { | |
| 251 | + | bounds := img.Bounds() | |
| 252 | + | ||
| 253 | + | // Sample pixels to calculate statistics | |
| 254 | + | var totalR, totalG, totalB uint32 | |
| 255 | + | var pixelCount uint32 | |
| 256 | + | var colorVariance uint32 | |
| 257 | + | var transparentPixels uint32 | |
| 258 | + | ||
| 259 | + | // Sample every 4th pixel for performance | |
| 260 | + | for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 { | |
| 261 | + | for x := bounds.Min.X; x < bounds.Max.X; x += 4 { | |
| 262 | + | r, g, b, a := img.At(x, y).RGBA() | |
| 263 | + | ||
| 264 | + | if a == 0 { | |
| 265 | + | transparentPixels++ | |
| 266 | + | continue | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | if a > 0 { // Only count non-transparent pixels | |
| 270 | + | totalR += r | |
| 271 | + | totalG += g | |
| 272 | + | totalB += b | |
| 273 | + | pixelCount++ | |
| 274 | + | ||
| 275 | + | // Calculate color variance to detect colorful vs monochrome icons | |
| 276 | + | // High variance = colorful (don't invert), low variance = monochrome | |
| 277 | + | rNorm := r >> 8 // Convert to 0-255 range | |
| 278 | + | gNorm := g >> 8 | |
| 279 | + | bNorm := b >> 8 | |
| 280 | + | ||
| 281 | + | // Simple color variance: difference between max and min color channels | |
| 282 | + | maxColor := rNorm | |
| 283 | + | if gNorm > maxColor { | |
| 284 | + | maxColor = gNorm | |
| 285 | + | } | |
| 286 | + | if bNorm > maxColor { | |
| 287 | + | maxColor = bNorm | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | minColor := rNorm | |
| 291 | + | if gNorm < minColor { | |
| 292 | + | minColor = gNorm | |
| 293 | + | } | |
| 294 | + | if bNorm < minColor { | |
| 295 | + | minColor = bNorm | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | colorVariance += maxColor - minColor | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | if pixelCount == 0 { | |
| 304 | + | return img // All transparent | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // Calculate average brightness (0-65535 range) | |
| 308 | + | avgR := totalR / pixelCount | |
| 309 | + | avgG := totalG / pixelCount | |
| 310 | + | avgB := totalB / pixelCount | |
| 311 | + | ||
| 312 | + | // Calculate perceived brightness using standard luminance formula | |
| 313 | + | brightness := (299*avgR + 587*avgG + 114*avgB) / 1000 | |
| 314 | + | ||
| 315 | + | // Calculate average color variance (0-255 range) | |
| 316 | + | avgColorVariance := colorVariance / pixelCount | |
| 317 | + | ||
| 318 | + | // Calculate transparency ratio | |
| 319 | + | totalPixels := pixelCount + transparentPixels | |
| 320 | + | transparencyRatio := float64(transparentPixels) / float64(totalPixels) | |
| 321 | + | ||
| 322 | + | // Only invert if: | |
| 323 | + | // 1. Icon is very dark (brightness < 20000, low brightness) | |
| 324 | + | // 2. AND icon is mostly monochrome (low color variance < 20) | |
| 325 | + | // 3. AND icon is not mostly transparent (< 80% transparent) | |
| 326 | + | // This makes dark monochrome icons visible on dark themes while preserving colorful icons | |
| 327 | + | ||
| 328 | + | shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8 | |
| 329 | + | ||
| 330 | + | log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t", | |
| 331 | + | brightness, avgColorVariance, transparencyRatio, shouldInvert) | |
| 332 | + | ||
| 333 | + | if shouldInvert { | |
| 334 | + | log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility") | |
| 335 | + | return invertImage(img) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | return img | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // invertImage inverts the colors of an image with edge smoothing | |
| 342 | + | func invertImage(img image.Image) image.Image { | |
| 343 | + | bounds := img.Bounds() | |
| 344 | + | inverted := image.NewRGBA(bounds) | |
| 345 | + | ||
| 346 | + | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { | |
| 347 | + | for x := bounds.Min.X; x < bounds.Max.X; x++ { | |
| 348 | + | r, g, b, a := img.At(x, y).RGBA() | |
| 349 | + | ||
| 350 | + | // Skip fully transparent pixels to avoid noise | |
| 351 | + | if a == 0 { | |
| 352 | + | inverted.Set(x, y, color.RGBA{A: 0}) | |
| 353 | + | continue | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | // Convert to 8-bit for easier processing | |
| 357 | + | r8 := uint8(r >> 8) | |
| 358 | + | g8 := uint8(g >> 8) | |
| 359 | + | b8 := uint8(b >> 8) | |
| 360 | + | a8 := uint8(a >> 8) | |
| 361 | + | ||
| 362 | + | // Invert RGB but preserve alpha | |
| 363 | + | newR := 255 - r8 | |
| 364 | + | newG := 255 - g8 | |
| 365 | + | newB := 255 - b8 | |
| 366 | + | ||
| 367 | + | // For semi-transparent pixels, blend the inversion more gently | |
| 368 | + | // This reduces edge noise by making transitions smoother | |
| 369 | + | if a8 < 255 { | |
| 370 | + | // Reduce inversion intensity for semi-transparent pixels | |
| 371 | + | blendFactor := float64(a8) / 255.0 | |
| 372 | + | newR = uint8(float64(r8) + (float64(newR-r8) * blendFactor)) | |
| 373 | + | newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor)) | |
| 374 | + | newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor)) | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | inverted.Set(x, y, color.RGBA{ | |
| 378 | + | R: newR, | |
| 379 | + | G: newG, | |
| 380 | + | B: newB, | |
| 381 | + | A: a8, | |
| 382 | + | }) | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | return inverted | |
| 387 | + | }⏎̸ | |
| @@ -0,0 +1,387 @@ | |||||
| 1 | + | package cache | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/md5" | |||
| 5 | + | "fmt" | |||
| 6 | + | "image" | |||
| 7 | + | "image/color" | |||
| 8 | + | "image/png" | |||
| 9 | + | _ "image/jpeg" // Register JPEG decoder | |||
| 10 | + | _ "image/gif" // Register GIF decoder | |||
| 11 | + | "io" | |||
| 12 | + | "log" | |||
| 13 | + | "net/http" | |||
| 14 | + | "os" | |||
| 15 | + | "path/filepath" | |||
| 16 | + | "strings" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "github.com/bnema/dumber/internal/config" | |||
| 20 | + | "github.com/sergeymakinen/go-ico" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // FaviconCache manages favicon caching to local files | |||
| 24 | + | type FaviconCache struct { | |||
| 25 | + | cacheDir string | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // NewFaviconCache creates a new favicon cache | |||
| 29 | + | func NewFaviconCache() (*FaviconCache, error) { | |||
| 30 | + | dataDir, err := config.GetDataDir() | |||
| 31 | + | if err != nil { | |||
| 32 | + | return nil, fmt.Errorf("failed to get data directory: %w", err) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | cacheDir := filepath.Join(dataDir, "favicons") | |||
| 36 | + | if err := os.MkdirAll(cacheDir, 0755); err != nil { | |||
| 37 | + | return nil, fmt.Errorf("failed to create favicon cache directory: %w", err) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | return &FaviconCache{ | |||
| 41 | + | cacheDir: cacheDir, | |||
| 42 | + | }, nil | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // GetCachedPath returns the local file path for a favicon URL if cached, empty string otherwise | |||
| 46 | + | func (fc *FaviconCache) GetCachedPath(faviconURL string) string { | |||
| 47 | + | if faviconURL == "" { | |||
| 48 | + | return "" | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | filename := fc.getFilename(faviconURL) | |||
| 52 | + | cachedPath := filepath.Join(fc.cacheDir, filename) | |||
| 53 | + | ||||
| 54 | + | // Check if file exists and is not too old (7 days) | |||
| 55 | + | if info, err := os.Stat(cachedPath); err == nil { | |||
| 56 | + | if time.Since(info.ModTime()) < 7*24*time.Hour { | |||
| 57 | + | return cachedPath | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | return "" | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // CacheAsync downloads and caches a favicon asynchronously | |||
| 65 | + | func (fc *FaviconCache) CacheAsync(faviconURL string) { | |||
| 66 | + | if faviconURL == "" { | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | go func() { | |||
| 71 | + | if err := fc.downloadFavicon(faviconURL); err != nil { | |||
| 72 | + | // Silently ignore errors in background download | |||
| 73 | + | } | |||
| 74 | + | }() | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // getFilename generates a safe filename for a favicon URL - always PNG for fuzzel compatibility | |||
| 78 | + | func (fc *FaviconCache) getFilename(faviconURL string) string { | |||
| 79 | + | hash := fmt.Sprintf("%x", md5.Sum([]byte(faviconURL))) | |||
| 80 | + | return hash + ".png" | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // downloadFavicon downloads a favicon and saves it to cache | |||
| 84 | + | func (fc *FaviconCache) downloadFavicon(faviconURL string) error { | |||
| 85 | + | filename := fc.getFilename(faviconURL) | |||
| 86 | + | cachedPath := filepath.Join(fc.cacheDir, filename) | |||
| 87 | + | ||||
| 88 | + | // Don't download if already exists and is recent | |||
| 89 | + | if info, err := os.Stat(cachedPath); err == nil { | |||
| 90 | + | if time.Since(info.ModTime()) < 7*24*time.Hour { | |||
| 91 | + | return nil | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Create HTTP client with timeout | |||
| 96 | + | client := &http.Client{ | |||
| 97 | + | Timeout: 10 * time.Second, | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | resp, err := client.Get(faviconURL) | |||
| 101 | + | if err != nil { | |||
| 102 | + | return fmt.Errorf("failed to download favicon: %w", err) | |||
| 103 | + | } | |||
| 104 | + | defer resp.Body.Close() | |||
| 105 | + | ||||
| 106 | + | if resp.StatusCode != http.StatusOK { | |||
| 107 | + | return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // Limit response body size | |||
| 111 | + | limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max | |||
| 112 | + | ||||
| 113 | + | // Download to temporary file first | |||
| 114 | + | tempDownloadFile := cachedPath + ".download" | |||
| 115 | + | downloadFile, err := os.Create(tempDownloadFile) | |||
| 116 | + | if err != nil { | |||
| 117 | + | return fmt.Errorf("failed to create download temp file: %w", err) | |||
| 118 | + | } | |||
| 119 | + | defer downloadFile.Close() | |||
| 120 | + | defer os.Remove(tempDownloadFile) | |||
| 121 | + | ||||
| 122 | + | // Save raw favicon data | |||
| 123 | + | if _, err := io.Copy(downloadFile, limitedBody); err != nil { | |||
| 124 | + | return fmt.Errorf("failed to save favicon data: %w", err) | |||
| 125 | + | } | |||
| 126 | + | downloadFile.Close() | |||
| 127 | + | ||||
| 128 | + | // Detect if it's an ICO file and convert with ffmpeg if needed | |||
| 129 | + | if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil { | |||
| 130 | + | return fmt.Errorf("failed to convert favicon: %w", err) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | return nil | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // convertToPNG converts a favicon file to PNG format | |||
| 137 | + | func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) error { | |||
| 138 | + | // Check if it's an ICO file based on URL or content type | |||
| 139 | + | if strings.Contains(strings.ToLower(faviconURL), ".ico") { | |||
| 140 | + | log.Printf("[favicon] ICO file detected for %s, using go-ico decoder", faviconURL) | |||
| 141 | + | return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // Try standard Go image decoder for other formats (PNG, JPEG, GIF) | |||
| 145 | + | if img, err := fc.tryStandardImageDecode(inputPath); err == nil { | |||
| 146 | + | log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL) | |||
| 147 | + | ||||
| 148 | + | // Apply dark theme improvements | |||
| 149 | + | img = invertIfTooDark(img) | |||
| 150 | + | ||||
| 151 | + | // Save as PNG | |||
| 152 | + | return fc.savePNG(img, outputPath) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // Standard decode failed, fallback to go-ico | |||
| 156 | + | log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL) | |||
| 157 | + | return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // tryStandardImageDecode attempts to decode image with Go standard library | |||
| 161 | + | func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, error) { | |||
| 162 | + | file, err := os.Open(filePath) | |||
| 163 | + | if err != nil { | |||
| 164 | + | return nil, err | |||
| 165 | + | } | |||
| 166 | + | defer file.Close() | |||
| 167 | + | ||||
| 168 | + | img, _, err := image.Decode(file) | |||
| 169 | + | return img, err | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // savePNG saves an image as PNG | |||
| 173 | + | func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error { | |||
| 174 | + | tempFile := outputPath + ".tmp" | |||
| 175 | + | file, err := os.Create(tempFile) | |||
| 176 | + | if err != nil { | |||
| 177 | + | return fmt.Errorf("failed to create temp file: %w", err) | |||
| 178 | + | } | |||
| 179 | + | defer file.Close() | |||
| 180 | + | ||||
| 181 | + | if err := png.Encode(file, img); err != nil { | |||
| 182 | + | os.Remove(tempFile) | |||
| 183 | + | return fmt.Errorf("failed to encode PNG: %w", err) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | file.Close() | |||
| 187 | + | ||||
| 188 | + | // Atomic move | |||
| 189 | + | if err := os.Rename(tempFile, outputPath); err != nil { | |||
| 190 | + | os.Remove(tempFile) | |||
| 191 | + | return fmt.Errorf("failed to move PNG to cache: %w", err) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // convertICOWithGoICO converts ICO files to PNG using go-ico library | |||
| 198 | + | func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL string) error { | |||
| 199 | + | // Open the ICO file | |||
| 200 | + | file, err := os.Open(inputPath) | |||
| 201 | + | if err != nil { | |||
| 202 | + | return fmt.Errorf("failed to open ICO file: %w", err) | |||
| 203 | + | } | |||
| 204 | + | defer file.Close() | |||
| 205 | + | ||||
| 206 | + | // Decode ICO file | |||
| 207 | + | img, err := ico.Decode(file) | |||
| 208 | + | if err != nil { | |||
| 209 | + | return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL) | |||
| 213 | + | ||||
| 214 | + | // Apply dark theme improvements | |||
| 215 | + | img = invertIfTooDark(img) | |||
| 216 | + | ||||
| 217 | + | // Save as PNG | |||
| 218 | + | return fc.savePNG(img, outputPath) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // CleanOld removes cached favicons older than 30 days | |||
| 222 | + | func (fc *FaviconCache) CleanOld() error { | |||
| 223 | + | entries, err := os.ReadDir(fc.cacheDir) | |||
| 224 | + | if err != nil { | |||
| 225 | + | return err | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | cutoff := time.Now().Add(-30 * 24 * time.Hour) | |||
| 229 | + | ||||
| 230 | + | for _, entry := range entries { | |||
| 231 | + | if entry.IsDir() { | |||
| 232 | + | continue | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | info, err := entry.Info() | |||
| 236 | + | if err != nil { | |||
| 237 | + | continue | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | if info.ModTime().Before(cutoff) { | |||
| 241 | + | os.Remove(filepath.Join(fc.cacheDir, entry.Name())) | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | return nil | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // invertIfTooDark inverts icons that are too dark for dark theme visibility | |||
| 249 | + | // Only inverts truly monochrome dark icons, preserves colorful icons | |||
| 250 | + | func invertIfTooDark(img image.Image) image.Image { | |||
| 251 | + | bounds := img.Bounds() | |||
| 252 | + | ||||
| 253 | + | // Sample pixels to calculate statistics | |||
| 254 | + | var totalR, totalG, totalB uint32 | |||
| 255 | + | var pixelCount uint32 | |||
| 256 | + | var colorVariance uint32 | |||
| 257 | + | var transparentPixels uint32 | |||
| 258 | + | ||||
| 259 | + | // Sample every 4th pixel for performance | |||
| 260 | + | for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 { | |||
| 261 | + | for x := bounds.Min.X; x < bounds.Max.X; x += 4 { | |||
| 262 | + | r, g, b, a := img.At(x, y).RGBA() | |||
| 263 | + | ||||
| 264 | + | if a == 0 { | |||
| 265 | + | transparentPixels++ | |||
| 266 | + | continue | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | if a > 0 { // Only count non-transparent pixels | |||
| 270 | + | totalR += r | |||
| 271 | + | totalG += g | |||
| 272 | + | totalB += b | |||
| 273 | + | pixelCount++ | |||
| 274 | + | ||||
| 275 | + | // Calculate color variance to detect colorful vs monochrome icons | |||
| 276 | + | // High variance = colorful (don't invert), low variance = monochrome | |||
| 277 | + | rNorm := r >> 8 // Convert to 0-255 range | |||
| 278 | + | gNorm := g >> 8 | |||
| 279 | + | bNorm := b >> 8 | |||
| 280 | + | ||||
| 281 | + | // Simple color variance: difference between max and min color channels | |||
| 282 | + | maxColor := rNorm | |||
| 283 | + | if gNorm > maxColor { | |||
| 284 | + | maxColor = gNorm | |||
| 285 | + | } | |||
| 286 | + | if bNorm > maxColor { | |||
| 287 | + | maxColor = bNorm | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | minColor := rNorm | |||
| 291 | + | if gNorm < minColor { | |||
| 292 | + | minColor = gNorm | |||
| 293 | + | } | |||
| 294 | + | if bNorm < minColor { | |||
| 295 | + | minColor = bNorm | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | colorVariance += maxColor - minColor | |||
| 299 | + | } | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | if pixelCount == 0 { | |||
| 304 | + | return img // All transparent | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // Calculate average brightness (0-65535 range) | |||
| 308 | + | avgR := totalR / pixelCount | |||
| 309 | + | avgG := totalG / pixelCount | |||
| 310 | + | avgB := totalB / pixelCount | |||
| 311 | + | ||||
| 312 | + | // Calculate perceived brightness using standard luminance formula | |||
| 313 | + | brightness := (299*avgR + 587*avgG + 114*avgB) / 1000 | |||
| 314 | + | ||||
| 315 | + | // Calculate average color variance (0-255 range) | |||
| 316 | + | avgColorVariance := colorVariance / pixelCount | |||
| 317 | + | ||||
| 318 | + | // Calculate transparency ratio | |||
| 319 | + | totalPixels := pixelCount + transparentPixels | |||
| 320 | + | transparencyRatio := float64(transparentPixels) / float64(totalPixels) | |||
| 321 | + | ||||
| 322 | + | // Only invert if: | |||
| 323 | + | // 1. Icon is very dark (brightness < 20000, low brightness) | |||
| 324 | + | // 2. AND icon is mostly monochrome (low color variance < 20) | |||
| 325 | + | // 3. AND icon is not mostly transparent (< 80% transparent) | |||
| 326 | + | // This makes dark monochrome icons visible on dark themes while preserving colorful icons | |||
| 327 | + | ||||
| 328 | + | shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8 | |||
| 329 | + | ||||
| 330 | + | log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t", | |||
| 331 | + | brightness, avgColorVariance, transparencyRatio, shouldInvert) | |||
| 332 | + | ||||
| 333 | + | if shouldInvert { | |||
| 334 | + | log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility") | |||
| 335 | + | return invertImage(img) | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | return img | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // invertImage inverts the colors of an image with edge smoothing | |||
| 342 | + | func invertImage(img image.Image) image.Image { | |||
| 343 | + | bounds := img.Bounds() | |||
| 344 | + | inverted := image.NewRGBA(bounds) | |||
| 345 | + | ||||
| 346 | + | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { | |||
| 347 | + | for x := bounds.Min.X; x < bounds.Max.X; x++ { | |||
| 348 | + | r, g, b, a := img.At(x, y).RGBA() | |||
| 349 | + | ||||
| 350 | + | // Skip fully transparent pixels to avoid noise | |||
| 351 | + | if a == 0 { | |||
| 352 | + | inverted.Set(x, y, color.RGBA{A: 0}) | |||
| 353 | + | continue | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | // Convert to 8-bit for easier processing | |||
| 357 | + | r8 := uint8(r >> 8) | |||
| 358 | + | g8 := uint8(g >> 8) | |||
| 359 | + | b8 := uint8(b >> 8) | |||
| 360 | + | a8 := uint8(a >> 8) | |||
| 361 | + | ||||
| 362 | + | // Invert RGB but preserve alpha | |||
| 363 | + | newR := 255 - r8 | |||
| 364 | + | newG := 255 - g8 | |||
| 365 | + | newB := 255 - b8 | |||
| 366 | + | ||||
| 367 | + | // For semi-transparent pixels, blend the inversion more gently | |||
| 368 | + | // This reduces edge noise by making transitions smoother | |||
| 369 | + | if a8 < 255 { | |||
| 370 | + | // Reduce inversion intensity for semi-transparent pixels | |||
| 371 | + | blendFactor := float64(a8) / 255.0 | |||
| 372 | + | newR = uint8(float64(r8) + (float64(newR-r8) * blendFactor)) | |||
| 373 | + | newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor)) | |||
| 374 | + | newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor)) | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | inverted.Set(x, y, color.RGBA{ | |||
| 378 | + | R: newR, | |||
| 379 | + | G: newG, | |||
| 380 | + | B: newB, | |||
| 381 | + | A: a8, | |||
| 382 | + | }) | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | return inverted | |||
| 387 | + | }⏎̸ | |||
M internal/cache/fuzzy_cache.go +12 -6
| @@ -171,9 +171,15 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache, | |||
| 171 | 171 | lastVisited = entry.LastVisited.Time | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | + | faviconURL := "" | |
| 175 | + | if entry.FaviconUrl.Valid { | |
| 176 | + | faviconURL = entry.FaviconUrl.String | |
| 177 | + | } | |
| 178 | + | ||
| 174 | 179 | newCache.entries[i] = NewCompactEntry( | |
| 175 | 180 | entry.Url, | |
| 176 | 181 | title, | |
| 182 | + | faviconURL, | |
| 177 | 183 | visitCount, | |
| 178 | 184 | lastVisited, | |
| 179 | 185 | ) | |
| @@ -193,7 +199,7 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache, | |||
| 193 | 199 | // Save to filesystem in background | |
| 194 | 200 | go cm.saveToFilesystemAsync() | |
| 195 | 201 | ||
| 196 | - | fmt.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history)) | |
| 202 | + | log.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history)) | |
| 197 | 203 | return cm.cache, nil | |
| 198 | 204 | } | |
| 199 | 205 | ||
| @@ -216,7 +222,7 @@ func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) { | |||
| 216 | 222 | // Build new cache | |
| 217 | 223 | _, err := cm.buildCacheFromDB(ctx) | |
| 218 | 224 | if err != nil { | |
| 219 | - | fmt.Printf("Warning: failed to refresh cache: %v\n", err) | |
| 225 | + | log.Printf("Warning: failed to refresh cache: %v\n", err) | |
| 220 | 226 | } | |
| 221 | 227 | }() | |
| 222 | 228 | } | |
| @@ -233,7 +239,7 @@ func (cm *CacheManager) OnApplicationExit(ctx context.Context) { | |||
| 233 | 239 | ||
| 234 | 240 | _, err := cm.buildCacheFromDB(ctx) | |
| 235 | 241 | if err != nil { | |
| 236 | - | fmt.Printf("Warning: failed to refresh cache on exit: %v\n", err) | |
| 242 | + | log.Printf("Warning: failed to refresh cache on exit: %v\n", err) | |
| 237 | 243 | } | |
| 238 | 244 | }() | |
| 239 | 245 | } | |
| @@ -244,14 +250,14 @@ func (cm *CacheManager) saveToFilesystemAsync() { | |||
| 244 | 250 | // Create cache directory if it doesn't exist | |
| 245 | 251 | cacheDir := filepath.Dir(cm.config.CacheFile) | |
| 246 | 252 | if err := os.MkdirAll(cacheDir, dirPerm); err != nil { | |
| 247 | - | fmt.Printf("Warning: failed to create cache directory: %v\n", err) | |
| 253 | + | log.Printf("Warning: failed to create cache directory: %v\n", err) | |
| 248 | 254 | return | |
| 249 | 255 | } | |
| 250 | 256 | ||
| 251 | 257 | // Save to temporary file first, then atomic rename | |
| 252 | 258 | tempFile := cm.config.CacheFile + ".tmp" | |
| 253 | 259 | if err := cm.cache.SaveToBinary(tempFile); err != nil { | |
| 254 | - | fmt.Printf("Warning: failed to save cache: %v\n", err) | |
| 260 | + | log.Printf("Warning: failed to save cache: %v\n", err) | |
| 255 | 261 | if err := os.Remove(tempFile); err != nil { | |
| 256 | 262 | log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err) | |
| 257 | 263 | } | |
| @@ -260,7 +266,7 @@ func (cm *CacheManager) saveToFilesystemAsync() { | |||
| 260 | 266 | ||
| 261 | 267 | // Atomic rename | |
| 262 | 268 | if err := os.Rename(tempFile, cm.config.CacheFile); err != nil { | |
| 263 | - | fmt.Printf("Warning: failed to rename cache file: %v\n", err) | |
| 269 | + | log.Printf("Warning: failed to rename cache file: %v\n", err) | |
| 264 | 270 | if err := os.Remove(tempFile); err != nil { | |
| 265 | 271 | log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err) | |
| 266 | 272 | } | |
| @@ -171,9 +171,15 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache, | |||||
| 171 | lastVisited = entry.LastVisited.Time | 171 | lastVisited = entry.LastVisited.Time | ||
| 172 | } | 172 | } | ||
| 173 | 173 | ||||
| 174 | + | faviconURL := "" | |||
| 175 | + | if entry.FaviconUrl.Valid { | |||
| 176 | + | faviconURL = entry.FaviconUrl.String | |||
| 177 | + | } | |||
| 178 | + | ||||
| 174 | newCache.entries[i] = NewCompactEntry( | 179 | newCache.entries[i] = NewCompactEntry( | ||
| 175 | entry.Url, | 180 | entry.Url, | ||
| 176 | title, | 181 | title, | ||
| 182 | + | faviconURL, | |||
| 177 | visitCount, | 183 | visitCount, | ||
| 178 | lastVisited, | 184 | lastVisited, | ||
| 179 | ) | 185 | ) | ||
| @@ -193,7 +199,7 @@ func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache, | |||||
| 193 | // Save to filesystem in background | 199 | // Save to filesystem in background | ||
| 194 | go cm.saveToFilesystemAsync() | 200 | go cm.saveToFilesystemAsync() | ||
| 195 | 201 | ||||
| 196 | - | fmt.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history)) | 202 | + | log.Printf("Cache built in %v with %d entries\n", time.Since(startTime), len(history)) |
| 197 | return cm.cache, nil | 203 | return cm.cache, nil | ||
| 198 | } | 204 | } | ||
| 199 | 205 | ||||
| @@ -216,7 +222,7 @@ func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) { | |||||
| 216 | // Build new cache | 222 | // Build new cache | ||
| 217 | _, err := cm.buildCacheFromDB(ctx) | 223 | _, err := cm.buildCacheFromDB(ctx) | ||
| 218 | if err != nil { | 224 | if err != nil { | ||
| 219 | - | fmt.Printf("Warning: failed to refresh cache: %v\n", err) | 225 | + | log.Printf("Warning: failed to refresh cache: %v\n", err) |
| 220 | } | 226 | } | ||
| 221 | }() | 227 | }() | ||
| 222 | } | 228 | } | ||
| @@ -233,7 +239,7 @@ func (cm *CacheManager) OnApplicationExit(ctx context.Context) { | |||||
| 233 | 239 | ||||
| 234 | _, err := cm.buildCacheFromDB(ctx) | 240 | _, err := cm.buildCacheFromDB(ctx) | ||
| 235 | if err != nil { | 241 | if err != nil { | ||
| 236 | - | fmt.Printf("Warning: failed to refresh cache on exit: %v\n", err) | 242 | + | log.Printf("Warning: failed to refresh cache on exit: %v\n", err) |
| 237 | } | 243 | } | ||
| 238 | }() | 244 | }() | ||
| 239 | } | 245 | } | ||
| @@ -244,14 +250,14 @@ func (cm *CacheManager) saveToFilesystemAsync() { | |||||
| 244 | // Create cache directory if it doesn't exist | 250 | // Create cache directory if it doesn't exist | ||
| 245 | cacheDir := filepath.Dir(cm.config.CacheFile) | 251 | cacheDir := filepath.Dir(cm.config.CacheFile) | ||
| 246 | if err := os.MkdirAll(cacheDir, dirPerm); err != nil { | 252 | if err := os.MkdirAll(cacheDir, dirPerm); err != nil { | ||
| 247 | - | fmt.Printf("Warning: failed to create cache directory: %v\n", err) | 253 | + | log.Printf("Warning: failed to create cache directory: %v\n", err) |
| 248 | return | 254 | return | ||
| 249 | } | 255 | } | ||
| 250 | 256 | ||||
| 251 | // Save to temporary file first, then atomic rename | 257 | // Save to temporary file first, then atomic rename | ||
| 252 | tempFile := cm.config.CacheFile + ".tmp" | 258 | tempFile := cm.config.CacheFile + ".tmp" | ||
| 253 | if err := cm.cache.SaveToBinary(tempFile); err != nil { | 259 | if err := cm.cache.SaveToBinary(tempFile); err != nil { | ||
| 254 | - | fmt.Printf("Warning: failed to save cache: %v\n", err) | 260 | + | log.Printf("Warning: failed to save cache: %v\n", err) |
| 255 | if err := os.Remove(tempFile); err != nil { | 261 | if err := os.Remove(tempFile); err != nil { | ||
| 256 | log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err) | 262 | log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err) | ||
| 257 | } | 263 | } | ||
| @@ -260,7 +266,7 @@ func (cm *CacheManager) saveToFilesystemAsync() { | |||||
| 260 | 266 | ||||
| 261 | // Atomic rename | 267 | // Atomic rename | ||
| 262 | if err := os.Rename(tempFile, cm.config.CacheFile); err != nil { | 268 | if err := os.Rename(tempFile, cm.config.CacheFile); err != nil { | ||
| 263 | - | fmt.Printf("Warning: failed to rename cache file: %v\n", err) | 269 | + | log.Printf("Warning: failed to rename cache file: %v\n", err) |
| 264 | if err := os.Remove(tempFile); err != nil { | 270 | if err := os.Remove(tempFile); err != nil { | ||
| 265 | log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err) | 271 | log.Printf("Warning: failed to remove temp file %s: %v", tempFile, err) | ||
| 266 | } | 272 | } | ||
M internal/cache/types.go +5 -3
| @@ -32,6 +32,7 @@ type DmenuFuzzyCache struct { | |||
| 32 | 32 | type CompactEntry struct { | |
| 33 | 33 | URL string // Original URL | |
| 34 | 34 | Title string // Page title | |
| 35 | + | FaviconURL string // URL to favicon for this entry | |
| 35 | 36 | VisitCount uint16 // Max 65535 visits (sufficient for most use cases) | |
| 36 | 37 | LastVisit uint32 // Days since Unix epoch (saves 4 bytes vs time.Time) | |
| 37 | 38 | Score uint16 // Pre-computed base score (0-65535) | |
| @@ -153,10 +154,11 @@ func TimeFromDays(days uint32) time.Time { | |||
| 153 | 154 | } | |
| 154 | 155 | ||
| 155 | 156 | // NewCompactEntry creates a CompactEntry from database values. | |
| 156 | - | func NewCompactEntry(url, title string, visitCount int64, lastVisit time.Time) CompactEntry { | |
| 157 | + | func NewCompactEntry(url, title, faviconURL string, visitCount int64, lastVisit time.Time) CompactEntry { | |
| 157 | 158 | entry := CompactEntry{ | |
| 158 | - | URL: url, | |
| 159 | - | Title: title, | |
| 159 | + | URL: url, | |
| 160 | + | Title: title, | |
| 161 | + | FaviconURL: faviconURL, | |
| 160 | 162 | } | |
| 161 | 163 | ||
| 162 | 164 | // Clamp visit count to uint16 max | |
| @@ -32,6 +32,7 @@ type DmenuFuzzyCache struct { | |||||
| 32 | type CompactEntry struct { | 32 | type CompactEntry struct { | ||
| 33 | URL string // Original URL | 33 | URL string // Original URL | ||
| 34 | Title string // Page title | 34 | Title string // Page title | ||
| 35 | + | FaviconURL string // URL to favicon for this entry | |||
| 35 | VisitCount uint16 // Max 65535 visits (sufficient for most use cases) | 36 | VisitCount uint16 // Max 65535 visits (sufficient for most use cases) | ||
| 36 | LastVisit uint32 // Days since Unix epoch (saves 4 bytes vs time.Time) | 37 | LastVisit uint32 // Days since Unix epoch (saves 4 bytes vs time.Time) | ||
| 37 | Score uint16 // Pre-computed base score (0-65535) | 38 | Score uint16 // Pre-computed base score (0-65535) | ||
| @@ -153,10 +154,11 @@ func TimeFromDays(days uint32) time.Time { | |||||
| 153 | } | 154 | } | ||
| 154 | 155 | ||||
| 155 | // NewCompactEntry creates a CompactEntry from database values. | 156 | // NewCompactEntry creates a CompactEntry from database values. | ||
| 156 | - | func NewCompactEntry(url, title string, visitCount int64, lastVisit time.Time) CompactEntry { | 157 | + | func NewCompactEntry(url, title, faviconURL string, visitCount int64, lastVisit time.Time) CompactEntry { |
| 157 | entry := CompactEntry{ | 158 | entry := CompactEntry{ | ||
| 158 | - | URL: url, | 159 | + | URL: url, |
| 159 | - | Title: title, | 160 | + | Title: title, |
| 161 | + | FaviconURL: faviconURL, | |||
| 160 | } | 162 | } | ||
| 161 | 163 | ||||
| 162 | // Clamp visit count to uint16 max | 164 | // Clamp visit count to uint16 max | ||
M internal/cli/browse.go +39 -76
| @@ -7,9 +7,9 @@ import ( | |||
| 7 | 7 | "net/url" | |
| 8 | 8 | "os" | |
| 9 | 9 | "os/exec" | |
| 10 | - | "regexp" | |
| 11 | 10 | "strings" | |
| 12 | 11 | ||
| 12 | + | "github.com/bnema/dumber/internal/cache" | |
| 13 | 13 | "github.com/bnema/dumber/internal/config" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/spf13/cobra" | |
| @@ -67,95 +67,27 @@ Direct URLs are also supported: | |||
| 67 | 67 | func browse(cli *CLI, input string) error { | |
| 68 | 68 | ctx := context.Background() | |
| 69 | 69 | ||
| 70 | - | // Parse the input to determine URL or shortcut | |
| 71 | - | finalURL, err := parseInput(ctx, cli, input) | |
| 70 | + | // Parse the input using parser service | |
| 71 | + | result, err := cli.ParserService.ParseInput(ctx, input) | |
| 72 | 72 | if err != nil { | |
| 73 | 73 | return fmt.Errorf("failed to parse input: %w", err) | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | finalURL := result.URL | |
| 77 | + | ||
| 76 | 78 | // Record in history | |
| 77 | 79 | if err := recordVisit(ctx, cli, finalURL, ""); err != nil { | |
| 78 | 80 | // Don't fail the browse operation if history recording fails | |
| 79 | 81 | fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err) | |
| 80 | 82 | } | |
| 81 | 83 | ||
| 84 | + | // Update favicon asynchronously (non-blocking) | |
| 85 | + | go updateFavicon(ctx, cli, finalURL) | |
| 86 | + | ||
| 82 | 87 | // Open URL using configuration | |
| 83 | 88 | return openURLWithConfig(finalURL, cli.Config) | |
| 84 | 89 | } | |
| 85 | 90 | ||
| 86 | - | // parseInput processes user input and returns the final URL to browse | |
| 87 | - | func parseInput(ctx context.Context, cli *CLI, input string) (string, error) { | |
| 88 | - | // Check if it's a shortcut (format: "prefix:query") | |
| 89 | - | if strings.Contains(input, ":") && !strings.Contains(input, "://") { | |
| 90 | - | parts := strings.SplitN(input, ":", 2) //nolint:mnd // split on first colon only | |
| 91 | - | if len(parts) == 2 { //nolint:mnd // expect prefix and query parts | |
| 92 | - | shortcut := strings.TrimSpace(parts[0]) | |
| 93 | - | query := strings.TrimSpace(parts[1]) | |
| 94 | - | ||
| 95 | - | if query == "" { | |
| 96 | - | return "", fmt.Errorf("empty query for shortcut '%s'", shortcut) | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | // First check configuration-based shortcuts | |
| 100 | - | if shortcutCfg, exists := cli.Config.SearchShortcuts[shortcut]; exists { | |
| 101 | - | return fmt.Sprintf(shortcutCfg.URL, url.QueryEscape(query)), nil | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | // Fallback to database shortcuts for backward compatibility | |
| 105 | - | shortcuts, err := cli.Queries.GetShortcuts(ctx) | |
| 106 | - | if err != nil { | |
| 107 | - | return "", fmt.Errorf("failed to get shortcuts: %w", err) | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | for _, s := range shortcuts { | |
| 111 | - | if s.Shortcut == shortcut { | |
| 112 | - | return fmt.Sprintf(s.UrlTemplate, url.QueryEscape(query)), nil | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | return "", fmt.Errorf("unknown shortcut '%s'", shortcut) | |
| 117 | - | } | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | // Check if it's already a valid URL | |
| 121 | - | if isValidURL(input) { | |
| 122 | - | return input, nil | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // Try to make it a URL by adding https:// | |
| 126 | - | if !strings.Contains(input, "://") { | |
| 127 | - | candidate := "https://" + input | |
| 128 | - | if isValidURL(candidate) { | |
| 129 | - | return candidate, nil | |
| 130 | - | } | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | // If all else fails, search Google for it | |
| 134 | - | return fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(input)), nil | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | // isValidURL checks if a string is a valid URL | |
| 138 | - | func isValidURL(str string) bool { | |
| 139 | - | u, err := url.Parse(str) | |
| 140 | - | if err != nil { | |
| 141 | - | return false | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | // Must have a scheme and host | |
| 145 | - | if u.Scheme == "" || u.Host == "" { | |
| 146 | - | return false | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | // Must be http or https | |
| 150 | - | if u.Scheme != "http" && u.Scheme != "https" { | |
| 151 | - | return false | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | // Basic domain validation | |
| 155 | - | domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?` + | |
| 156 | - | `(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) | |
| 157 | - | return domainRegex.MatchString(u.Host) | |
| 158 | - | } | |
| 159 | 91 | ||
| 160 | 92 | // recordVisit adds or updates a URL visit in the history | |
| 161 | 93 | func recordVisit(ctx context.Context, cli *CLI, url, title string) error { | |
| @@ -205,3 +137,34 @@ func openURLWithConfig(url string, cfg *config.Config) error { | |||
| 205 | 137 | return nil | |
| 206 | 138 | } | |
| 207 | 139 | ||
| 140 | + | // updateFavicon fetches and stores favicon URL for a given page URL | |
| 141 | + | func updateFavicon(ctx context.Context, cli *CLI, pageURL string) { | |
| 142 | + | parsedURL, err := url.Parse(pageURL) | |
| 143 | + | if err != nil { | |
| 144 | + | return // Silently fail for invalid URLs | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Skip favicon update for localhost, file://, or special schemes | |
| 148 | + | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | if strings.Contains(parsedURL.Host, "localhost") || strings.Contains(parsedURL.Host, "127.0.0.1") { | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // Standard favicon location | |
| 156 | + | faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host) | |
| 157 | + | ||
| 158 | + | // Update in database using the new sqlc-generated method | |
| 159 | + | faviconNullString := sql.NullString{String: faviconURL, Valid: true} | |
| 160 | + | if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | |
| 161 | + | // Silently fail - favicon is not critical | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Also cache the favicon for dmenu use | |
| 166 | + | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |
| 167 | + | faviconCache.CacheAsync(faviconURL) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| @@ -7,9 +7,9 @@ import ( | |||||
| 7 | "net/url" | 7 | "net/url" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | "os/exec" | 9 | "os/exec" | ||
| 10 | - | "regexp" | |||
| 11 | "strings" | 10 | "strings" | ||
| 12 | 11 | ||||
| 12 | + | "github.com/bnema/dumber/internal/cache" | |||
| 13 | "github.com/bnema/dumber/internal/config" | 13 | "github.com/bnema/dumber/internal/config" | ||
| 14 | 14 | ||||
| 15 | "github.com/spf13/cobra" | 15 | "github.com/spf13/cobra" | ||
| @@ -67,95 +67,27 @@ Direct URLs are also supported: | |||||
| 67 | func browse(cli *CLI, input string) error { | 67 | func browse(cli *CLI, input string) error { | ||
| 68 | ctx := context.Background() | 68 | ctx := context.Background() | ||
| 69 | 69 | ||||
| 70 | - | // Parse the input to determine URL or shortcut | 70 | + | // Parse the input using parser service |
| 71 | - | finalURL, err := parseInput(ctx, cli, input) | 71 | + | result, err := cli.ParserService.ParseInput(ctx, input) |
| 72 | if err != nil { | 72 | if err != nil { | ||
| 73 | return fmt.Errorf("failed to parse input: %w", err) | 73 | return fmt.Errorf("failed to parse input: %w", err) | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | finalURL := result.URL | |||
| 77 | + | ||||
| 76 | // Record in history | 78 | // Record in history | ||
| 77 | if err := recordVisit(ctx, cli, finalURL, ""); err != nil { | 79 | if err := recordVisit(ctx, cli, finalURL, ""); err != nil { | ||
| 78 | // Don't fail the browse operation if history recording fails | 80 | // Don't fail the browse operation if history recording fails | ||
| 79 | fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err) | 81 | fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err) | ||
| 80 | } | 82 | } | ||
| 81 | 83 | ||||
| 84 | + | // Update favicon asynchronously (non-blocking) | |||
| 85 | + | go updateFavicon(ctx, cli, finalURL) | |||
| 86 | + | ||||
| 82 | // Open URL using configuration | 87 | // Open URL using configuration | ||
| 83 | return openURLWithConfig(finalURL, cli.Config) | 88 | return openURLWithConfig(finalURL, cli.Config) | ||
| 84 | } | 89 | } | ||
| 85 | 90 | ||||
| 86 | - | // parseInput processes user input and returns the final URL to browse | |||
| 87 | - | func parseInput(ctx context.Context, cli *CLI, input string) (string, error) { | |||
| 88 | - | // Check if it's a shortcut (format: "prefix:query") | |||
| 89 | - | if strings.Contains(input, ":") && !strings.Contains(input, "://") { | |||
| 90 | - | parts := strings.SplitN(input, ":", 2) //nolint:mnd // split on first colon only | |||
| 91 | - | if len(parts) == 2 { //nolint:mnd // expect prefix and query parts | |||
| 92 | - | shortcut := strings.TrimSpace(parts[0]) | |||
| 93 | - | query := strings.TrimSpace(parts[1]) | |||
| 94 | - | ||||
| 95 | - | if query == "" { | |||
| 96 | - | return "", fmt.Errorf("empty query for shortcut '%s'", shortcut) | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | // First check configuration-based shortcuts | |||
| 100 | - | if shortcutCfg, exists := cli.Config.SearchShortcuts[shortcut]; exists { | |||
| 101 | - | return fmt.Sprintf(shortcutCfg.URL, url.QueryEscape(query)), nil | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | // Fallback to database shortcuts for backward compatibility | |||
| 105 | - | shortcuts, err := cli.Queries.GetShortcuts(ctx) | |||
| 106 | - | if err != nil { | |||
| 107 | - | return "", fmt.Errorf("failed to get shortcuts: %w", err) | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | for _, s := range shortcuts { | |||
| 111 | - | if s.Shortcut == shortcut { | |||
| 112 | - | return fmt.Sprintf(s.UrlTemplate, url.QueryEscape(query)), nil | |||
| 113 | - | } | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | return "", fmt.Errorf("unknown shortcut '%s'", shortcut) | |||
| 117 | - | } | |||
| 118 | - | } | |||
| 119 | - | ||||
| 120 | - | // Check if it's already a valid URL | |||
| 121 | - | if isValidURL(input) { | |||
| 122 | - | return input, nil | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | // Try to make it a URL by adding https:// | |||
| 126 | - | if !strings.Contains(input, "://") { | |||
| 127 | - | candidate := "https://" + input | |||
| 128 | - | if isValidURL(candidate) { | |||
| 129 | - | return candidate, nil | |||
| 130 | - | } | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | // If all else fails, search Google for it | |||
| 134 | - | return fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(input)), nil | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | // isValidURL checks if a string is a valid URL | |||
| 138 | - | func isValidURL(str string) bool { | |||
| 139 | - | u, err := url.Parse(str) | |||
| 140 | - | if err != nil { | |||
| 141 | - | return false | |||
| 142 | - | } | |||
| 143 | - | ||||
| 144 | - | // Must have a scheme and host | |||
| 145 | - | if u.Scheme == "" || u.Host == "" { | |||
| 146 | - | return false | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | // Must be http or https | |||
| 150 | - | if u.Scheme != "http" && u.Scheme != "https" { | |||
| 151 | - | return false | |||
| 152 | - | } | |||
| 153 | - | ||||
| 154 | - | // Basic domain validation | |||
| 155 | - | domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?` + | |||
| 156 | - | `(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) | |||
| 157 | - | return domainRegex.MatchString(u.Host) | |||
| 158 | - | } | |||
| 159 | 91 | ||||
| 160 | // recordVisit adds or updates a URL visit in the history | 92 | // recordVisit adds or updates a URL visit in the history | ||
| 161 | func recordVisit(ctx context.Context, cli *CLI, url, title string) error { | 93 | func recordVisit(ctx context.Context, cli *CLI, url, title string) error { | ||
| @@ -205,3 +137,34 @@ func openURLWithConfig(url string, cfg *config.Config) error { | |||||
| 205 | return nil | 137 | return nil | ||
| 206 | } | 138 | } | ||
| 207 | 139 | ||||
| 140 | + | // updateFavicon fetches and stores favicon URL for a given page URL | |||
| 141 | + | func updateFavicon(ctx context.Context, cli *CLI, pageURL string) { | |||
| 142 | + | parsedURL, err := url.Parse(pageURL) | |||
| 143 | + | if err != nil { | |||
| 144 | + | return // Silently fail for invalid URLs | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // Skip favicon update for localhost, file://, or special schemes | |||
| 148 | + | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { | |||
| 149 | + | return | |||
| 150 | + | } | |||
| 151 | + | if strings.Contains(parsedURL.Host, "localhost") || strings.Contains(parsedURL.Host, "127.0.0.1") { | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // Standard favicon location | |||
| 156 | + | faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host) | |||
| 157 | + | ||||
| 158 | + | // Update in database using the new sqlc-generated method | |||
| 159 | + | faviconNullString := sql.NullString{String: faviconURL, Valid: true} | |||
| 160 | + | if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | |||
| 161 | + | // Silently fail - favicon is not critical | |||
| 162 | + | return | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Also cache the favicon for dmenu use | |||
| 166 | + | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |||
| 167 | + | faviconCache.CacheAsync(faviconURL) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
M internal/cli/dmenu.go +147 -83
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bufio" | |
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | + | "net/url" | |
| 7 | 8 | "os" | |
| 8 | 9 | "sort" | |
| 9 | 10 | "strings" | |
| @@ -20,15 +21,15 @@ const ( | |||
| 20 | 21 | func NewDmenuCmd() *cobra.Command { | |
| 21 | 22 | cmd := &cobra.Command{ | |
| 22 | 23 | Use: "dmenu", | |
| 23 | - | Short: "Run in dmenu mode for launcher integration", | |
| 24 | - | Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers. | |
| 25 | - | This mode reads from stdin and outputs selectable options to stdout. | |
| 24 | + | Short: "Fast fuzzy history browser for launcher integration", | |
| 25 | + | Long: `Fast dmenu mode showing cached browsing history with fuzzy search. | |
| 26 | + | Uses binary tree cache for sub-millisecond performance. | |
| 26 | 27 | ||
| 27 | 28 | Usage with rofi: | |
| 28 | 29 | dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select | |
| 29 | 30 | ||
| 30 | - | Usage with dmenu: | |
| 31 | - | dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`, | |
| 31 | + | Usage with fuzzel: | |
| 32 | + | dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select`, | |
| 32 | 33 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 33 | 34 | selectFlag := cmd.Flag("select").Changed | |
| 34 | 35 | ||
| @@ -62,6 +63,13 @@ func generateOptions(cli *CLI) error { | |||
| 62 | 63 | cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries | |
| 63 | 64 | cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig) | |
| 64 | 65 | ||
| 66 | + | // Initialize favicon cache | |
| 67 | + | faviconCache, err := cache.NewFaviconCache() | |
| 68 | + | if err != nil { | |
| 69 | + | // Continue without favicon cache if it fails | |
| 70 | + | faviconCache = nil | |
| 71 | + | } | |
| 72 | + | ||
| 65 | 73 | // Get top entries from cache (this is blazingly fast!) | |
| 66 | 74 | result, err := cacheManager.GetTopEntries(ctx) | |
| 67 | 75 | if err != nil { | |
| @@ -69,77 +77,74 @@ func generateOptions(cli *CLI) error { | |||
| 69 | 77 | return generateOptionsFallback(cli) | |
| 70 | 78 | } | |
| 71 | 79 | ||
| 72 | - | // Get shortcuts for display | |
| 73 | - | shortcuts, err := cli.Queries.GetShortcuts(ctx) | |
| 74 | - | if err != nil { | |
| 75 | - | return fmt.Errorf("failed to get shortcuts: %w", err) | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // Generate options | |
| 79 | - | options := make([]DmenuOption, 0, len(result.Matches)+len(shortcuts)+1) | |
| 80 | - | ||
| 81 | - | // Add direct URL input option | |
| 82 | - | options = append(options, DmenuOption{ | |
| 83 | - | Display: "🌐 Enter URL or search query...", | |
| 84 | - | Value: "", | |
| 85 | - | Type: "input", | |
| 86 | - | Description: "Type any URL or search term", | |
| 87 | - | }) | |
| 88 | - | ||
| 89 | - | // Add shortcuts with examples | |
| 90 | - | for _, shortcut := range shortcuts { | |
| 91 | - | desc := shortcut.Description.String | |
| 92 | - | if !shortcut.Description.Valid { | |
| 93 | - | desc = "Custom shortcut" | |
| 94 | - | } | |
| 80 | + | // Fast dmenu mode: only shows cached history entries sorted by relevance | |
| 95 | 81 | ||
| 96 | - | options = append(options, DmenuOption{ | |
| 97 | - | Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc), | |
| 98 | - | Value: shortcut.Shortcut + ":", | |
| 99 | - | Type: "shortcut", | |
| 100 | - | Description: desc, | |
| 101 | - | }) | |
| 102 | - | } | |
| 82 | + | // Generate options - only show history entries from fast cache | |
| 83 | + | options := make([]DmenuOption, 0, len(result.Matches)) | |
| 103 | 84 | ||
| 104 | 85 | // Add history entries from cache (already sorted by relevance!) | |
| 105 | 86 | for _, match := range result.Matches { | |
| 106 | 87 | entry := match.Entry | |
| 107 | - | title := entry.URL | |
| 108 | - | if entry.Title != "" { | |
| 109 | - | title = entry.Title | |
| 88 | + | ||
| 89 | + | // Parse URL for domain extraction | |
| 90 | + | parsedURL, err := url.Parse(entry.URL) | |
| 91 | + | var domain string | |
| 92 | + | if err != nil || parsedURL.Host == "" { | |
| 93 | + | domain = "local" | |
| 94 | + | } else { | |
| 95 | + | domain = parsedURL.Host | |
| 110 | 96 | } | |
| 111 | - | ||
| 112 | - | const maxDisplayLength = 80 | |
| 113 | - | // Truncate long titles/URLs for display | |
| 114 | - | display := truncateString(title, maxDisplayLength) | |
| 115 | - | ||
| 97 | + | ||
| 98 | + | // Determine title | |
| 99 | + | title := entry.Title | |
| 100 | + | if title == "" || title == entry.URL { | |
| 101 | + | // Use meaningful fallback for untitled pages | |
| 102 | + | if parsedURL != nil && parsedURL.Path != "" && parsedURL.Path != "/" { | |
| 103 | + | title = fmt.Sprintf("[%s%s]", domain, parsedURL.Path) | |
| 104 | + | } else { | |
| 105 | + | title = fmt.Sprintf("[%s]", domain) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Check if it's a Google search result and format accordingly | |
| 110 | + | isGoogleSearch := domain == "www.google.com" && strings.Contains(entry.URL, "/search?q=") | |
| 111 | + | if isGoogleSearch { | |
| 112 | + | if parsedURL != nil { | |
| 113 | + | if q := parsedURL.Query().Get("q"); q != "" { | |
| 114 | + | title = fmt.Sprintf("Google: \"%s\"", q) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Format: "Title | domain.com | full-url" | |
| 120 | + | // Using pipe separator for rofi/dmenu compatibility | |
| 121 | + | display := fmt.Sprintf("%s | %s | %s", | |
| 122 | + | truncateString(title, 50), | |
| 123 | + | domain, | |
| 124 | + | truncateString(entry.URL, 70)) | |
| 125 | + | ||
| 126 | + | // Get favicon URL from cached entry | |
| 127 | + | faviconURL := match.Entry.FaviconURL | |
| 128 | + | ||
| 116 | 129 | options = append(options, DmenuOption{ | |
| 117 | - | Display: fmt.Sprintf("🕒 %s", display), | |
| 130 | + | Display: display, | |
| 118 | 131 | Value: entry.URL, | |
| 119 | 132 | Type: historyType, | |
| 120 | 133 | Description: entry.URL, | |
| 134 | + | FaviconURL: faviconURL, | |
| 121 | 135 | }) | |
| 122 | 136 | } | |
| 123 | 137 | ||
| 124 | - | // Sort options: input first, then shortcuts, then history (already sorted by cache) | |
| 125 | - | sort.Slice(options, func(i, j int) bool { | |
| 126 | - | if options[i].Type != options[j].Type { | |
| 127 | - | // Order: input, shortcut, history | |
| 128 | - | typeOrder := map[string]int{"input": 0, "shortcut": 1, historyType: 2} //nolint:mnd | |
| 129 | - | return typeOrder[options[i].Type] < typeOrder[options[j].Type] | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | if options[i].Type == historyType && options[j].Type == historyType { | |
| 133 | - | // History already sorted by cache relevance score | |
| 134 | - | return i < j | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | return options[i].Display < options[j].Display | |
| 138 | - | }) | |
| 138 | + | // No sorting needed - history entries are already sorted by relevance from cache | |
| 139 | 139 | ||
| 140 | - | // Output options to stdout | |
| 140 | + | // Output options to stdout with icon specifications | |
| 141 | 141 | for _, option := range options { | |
| 142 | - | fmt.Println(option.Display) | |
| 142 | + | iconName := getIconName(option, faviconCache) | |
| 143 | + | if iconName != "" { | |
| 144 | + | fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName) | |
| 145 | + | } else { | |
| 146 | + | fmt.Println(option.Display) | |
| 147 | + | } | |
| 143 | 148 | } | |
| 144 | 149 | ||
| 145 | 150 | // Trigger background cache refresh for next time if needed | |
| @@ -148,10 +153,17 @@ func generateOptions(cli *CLI) error { | |||
| 148 | 153 | return nil | |
| 149 | 154 | } | |
| 150 | 155 | ||
| 151 | - | // generateOptionsFallback is the old method used as a fallback if cache fails | |
| 156 | + | // generateOptionsFallback is the legacy method used as a fallback if fast cache fails | |
| 152 | 157 | func generateOptionsFallback(cli *CLI) error { | |
| 153 | 158 | ctx := context.Background() | |
| 154 | 159 | ||
| 160 | + | // Initialize favicon cache | |
| 161 | + | faviconCache, err := cache.NewFaviconCache() | |
| 162 | + | if err != nil { | |
| 163 | + | // Continue without favicon cache if it fails | |
| 164 | + | faviconCache = nil | |
| 165 | + | } | |
| 166 | + | ||
| 155 | 167 | const maxHistoryEntries = 50 | |
| 156 | 168 | // Get history entries | |
| 157 | 169 | history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries) | |
| @@ -226,9 +238,14 @@ func generateOptionsFallback(cli *CLI) error { | |||
| 226 | 238 | return options[i].Display < options[j].Display | |
| 227 | 239 | }) | |
| 228 | 240 | ||
| 229 | - | // Output options to stdout | |
| 241 | + | // Output options to stdout with icon specifications | |
| 230 | 242 | for _, option := range options { | |
| 231 | - | fmt.Println(option.Display) | |
| 243 | + | iconName := getIconName(option, faviconCache) | |
| 244 | + | if iconName != "" { | |
| 245 | + | fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName) | |
| 246 | + | } else { | |
| 247 | + | fmt.Println(option.Display) | |
| 248 | + | } | |
| 232 | 249 | } | |
| 233 | 250 | ||
| 234 | 251 | return nil | |
| @@ -246,42 +263,65 @@ func handleSelection(cli *CLI) error { | |||
| 246 | 263 | return fmt.Errorf("empty selection") | |
| 247 | 264 | } | |
| 248 | 265 | ||
| 249 | - | // Parse the selection to get the actual input | |
| 250 | - | input := parseSelection(selection) | |
| 251 | 266 | ||
| 252 | - | // If it's just the input prompt, read another line for the actual input | |
| 253 | - | if input == "" || input == "Enter URL or search query..." { | |
| 254 | - | fmt.Print("Enter URL or search query: ") | |
| 255 | - | if !scanner.Scan() { | |
| 256 | - | return fmt.Errorf("no input received") | |
| 257 | - | } | |
| 258 | - | input = strings.TrimSpace(scanner.Text()) | |
| 259 | - | } | |
| 267 | + | // Parse the selection to get the actual URL from history entry | |
| 268 | + | input := parseSelection(selection) | |
| 260 | 269 | ||
| 261 | 270 | if input == "" { | |
| 262 | 271 | return fmt.Errorf("empty input") | |
| 263 | 272 | } | |
| 264 | 273 | ||
| 265 | 274 | // Browse the selected/entered URL | |
| 266 | - | return browse(cli, input) | |
| 275 | + | err := browse(cli, input) | |
| 276 | + | ||
| 277 | + | // After successful browse, invalidate cache so next dmenu shows updated order | |
| 278 | + | if err == nil { | |
| 279 | + | ctx := context.Background() | |
| 280 | + | cacheConfig := cache.DefaultCacheConfig() | |
| 281 | + | cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig) | |
| 282 | + | cacheManager.InvalidateAndRefresh(ctx) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | return err | |
| 267 | 286 | } | |
| 268 | 287 | ||
| 288 | + | ||
| 269 | 289 | // DmenuOption represents a selectable option in dmenu mode | |
| 270 | 290 | type DmenuOption struct { | |
| 271 | 291 | Display string | |
| 272 | 292 | Value string | |
| 273 | 293 | Type string // "input", "shortcut", "history" | |
| 274 | 294 | Description string | |
| 295 | + | FaviconURL string // URL to favicon for this entry | |
| 275 | 296 | } | |
| 276 | 297 | ||
| 277 | - | // parseSelection extracts the actual URL/query from a dmenu selection | |
| 298 | + | // parseSelection extracts the actual URL from a dmenu selection | |
| 278 | 299 | func parseSelection(selection string) string { | |
| 279 | - | // Remove emoji prefixes and clean up the selection | |
| 280 | 300 | selection = strings.TrimSpace(selection) | |
| 281 | 301 | ||
| 282 | - | // Handle different option types | |
| 302 | + | // Strip icon protocol if present (format: "text\0icon\x1ficonname") | |
| 303 | + | if iconIndex := strings.Index(selection, "\x00icon\x1f"); iconIndex > 0 { | |
| 304 | + | selection = selection[:iconIndex] | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // 1. Handle new pipe-separated format: "Title | domain.com | full-url" | |
| 308 | + | if strings.Contains(selection, " | ") { | |
| 309 | + | parts := strings.Split(selection, " | ") | |
| 310 | + | if len(parts) >= 3 { | |
| 311 | + | // The URL is the last part (third field) | |
| 312 | + | return strings.TrimSpace(parts[len(parts)-1]) | |
| 313 | + | } else if len(parts) == 2 { | |
| 314 | + | // Might be "title | url" format, check if second part looks like URL | |
| 315 | + | lastPart := strings.TrimSpace(parts[1]) | |
| 316 | + | if strings.Contains(lastPart, "://") || strings.Contains(lastPart, ".") { | |
| 317 | + | return lastPart | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // Legacy format handling for backward compatibility | |
| 283 | 323 | if strings.HasPrefix(selection, "🌐 ") { | |
| 284 | - | // Input option selected | |
| 324 | + | // Input option selected (shouldn't happen with new format but just in case) | |
| 285 | 325 | return "" | |
| 286 | 326 | } | |
| 287 | 327 | ||
| @@ -294,15 +334,39 @@ func parseSelection(selection string) string { | |||
| 294 | 334 | } | |
| 295 | 335 | ||
| 296 | 336 | if strings.HasPrefix(selection, "🕒 ") { | |
| 297 | - | // History entry selected - this is the display title, need to extract URL | |
| 298 | - | // For now, we'll assume the display is the URL or close enough | |
| 299 | - | return strings.TrimPrefix(selection, "🕒 ") | |
| 337 | + | // History entry selected - remove emoji prefix | |
| 338 | + | selection = strings.TrimPrefix(selection, "🕒 ") | |
| 300 | 339 | } | |
| 301 | 340 | ||
| 302 | - | // Fallback: return the selection as-is | |
| 341 | + | // If no pipes and looks like a URL, return as-is | |
| 342 | + | if strings.Contains(selection, "://") { | |
| 343 | + | return selection | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // Fallback: treat as search query | |
| 303 | 347 | return selection | |
| 304 | 348 | } | |
| 305 | 349 | ||
| 350 | + | // getIconName determines the appropriate icon name for a dmenu option | |
| 351 | + | func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string { | |
| 352 | + | if option.Type != historyType { | |
| 353 | + | return "" | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | // Only use cached favicons - no system theme fallbacks for consistent sizing | |
| 357 | + | if option.FaviconURL != "" && faviconCache != nil { | |
| 358 | + | cachedPath := faviconCache.GetCachedPath(option.FaviconURL) | |
| 359 | + | if cachedPath != "" { | |
| 360 | + | return cachedPath | |
| 361 | + | } | |
| 362 | + | // Start async download for next time | |
| 363 | + | faviconCache.CacheAsync(option.FaviconURL) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | // No favicon available - return empty string to show no icon | |
| 367 | + | return "" | |
| 368 | + | } | |
| 369 | + | ||
| 306 | 370 | // truncateString truncates a string to the specified length with ellipsis | |
| 307 | 371 | func truncateString(s string, maxLen int) string { | |
| 308 | 372 | if len(s) <= maxLen { | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "net/url" | |||
| 7 | "os" | 8 | "os" | ||
| 8 | "sort" | 9 | "sort" | ||
| 9 | "strings" | 10 | "strings" | ||
| @@ -20,15 +21,15 @@ const ( | |||||
| 20 | func NewDmenuCmd() *cobra.Command { | 21 | func NewDmenuCmd() *cobra.Command { | ||
| 21 | cmd := &cobra.Command{ | 22 | cmd := &cobra.Command{ | ||
| 22 | Use: "dmenu", | 23 | Use: "dmenu", | ||
| 23 | - | Short: "Run in dmenu mode for launcher integration", | 24 | + | Short: "Fast fuzzy history browser for launcher integration", |
| 24 | - | Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers. | 25 | + | Long: `Fast dmenu mode showing cached browsing history with fuzzy search. |
| 25 | - | This mode reads from stdin and outputs selectable options to stdout. | 26 | + | Uses binary tree cache for sub-millisecond performance. |
| 26 | 27 | ||||
| 27 | Usage with rofi: | 28 | Usage with rofi: | ||
| 28 | dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select | 29 | dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select | ||
| 29 | 30 | ||||
| 30 | - | Usage with dmenu: | 31 | + | Usage with fuzzel: |
| 31 | - | dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`, | 32 | + | dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select`, |
| 32 | RunE: func(cmd *cobra.Command, _ []string) error { | 33 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| 33 | selectFlag := cmd.Flag("select").Changed | 34 | selectFlag := cmd.Flag("select").Changed | ||
| 34 | 35 | ||||
| @@ -62,6 +63,13 @@ func generateOptions(cli *CLI) error { | |||||
| 62 | cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries | 63 | cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries | ||
| 63 | cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig) | 64 | cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig) | ||
| 64 | 65 | ||||
| 66 | + | // Initialize favicon cache | |||
| 67 | + | faviconCache, err := cache.NewFaviconCache() | |||
| 68 | + | if err != nil { | |||
| 69 | + | // Continue without favicon cache if it fails | |||
| 70 | + | faviconCache = nil | |||
| 71 | + | } | |||
| 72 | + | ||||
| 65 | // Get top entries from cache (this is blazingly fast!) | 73 | // Get top entries from cache (this is blazingly fast!) | ||
| 66 | result, err := cacheManager.GetTopEntries(ctx) | 74 | result, err := cacheManager.GetTopEntries(ctx) | ||
| 67 | if err != nil { | 75 | if err != nil { | ||
| @@ -69,77 +77,74 @@ func generateOptions(cli *CLI) error { | |||||
| 69 | return generateOptionsFallback(cli) | 77 | return generateOptionsFallback(cli) | ||
| 70 | } | 78 | } | ||
| 71 | 79 | ||||
| 72 | - | // Get shortcuts for display | 80 | + | // Fast dmenu mode: only shows cached history entries sorted by relevance |
| 73 | - | shortcuts, err := cli.Queries.GetShortcuts(ctx) | |||
| 74 | - | if err != nil { | |||
| 75 | - | return fmt.Errorf("failed to get shortcuts: %w", err) | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | // Generate options | |||
| 79 | - | options := make([]DmenuOption, 0, len(result.Matches)+len(shortcuts)+1) | |||
| 80 | - | ||||
| 81 | - | // Add direct URL input option | |||
| 82 | - | options = append(options, DmenuOption{ | |||
| 83 | - | Display: "🌐 Enter URL or search query...", | |||
| 84 | - | Value: "", | |||
| 85 | - | Type: "input", | |||
| 86 | - | Description: "Type any URL or search term", | |||
| 87 | - | }) | |||
| 88 | - | ||||
| 89 | - | // Add shortcuts with examples | |||
| 90 | - | for _, shortcut := range shortcuts { | |||
| 91 | - | desc := shortcut.Description.String | |||
| 92 | - | if !shortcut.Description.Valid { | |||
| 93 | - | desc = "Custom shortcut" | |||
| 94 | - | } | |||
| 95 | 81 | ||||
| 96 | - | options = append(options, DmenuOption{ | 82 | + | // Generate options - only show history entries from fast cache |
| 97 | - | Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc), | 83 | + | options := make([]DmenuOption, 0, len(result.Matches)) |
| 98 | - | Value: shortcut.Shortcut + ":", | |||
| 99 | - | Type: "shortcut", | |||
| 100 | - | Description: desc, | |||
| 101 | - | }) | |||
| 102 | - | } | |||
| 103 | 84 | ||||
| 104 | // Add history entries from cache (already sorted by relevance!) | 85 | // Add history entries from cache (already sorted by relevance!) | ||
| 105 | for _, match := range result.Matches { | 86 | for _, match := range result.Matches { | ||
| 106 | entry := match.Entry | 87 | entry := match.Entry | ||
| 107 | - | title := entry.URL | 88 | + | |
| 108 | - | if entry.Title != "" { | 89 | + | // Parse URL for domain extraction |
| 109 | - | title = entry.Title | 90 | + | parsedURL, err := url.Parse(entry.URL) |
| 91 | + | var domain string | |||
| 92 | + | if err != nil || parsedURL.Host == "" { | |||
| 93 | + | domain = "local" | |||
| 94 | + | } else { | |||
| 95 | + | domain = parsedURL.Host | |||
| 110 | } | 96 | } | ||
| 111 | - | 97 | + | ||
| 112 | - | const maxDisplayLength = 80 | 98 | + | // Determine title |
| 113 | - | // Truncate long titles/URLs for display | 99 | + | title := entry.Title |
| 114 | - | display := truncateString(title, maxDisplayLength) | 100 | + | if title == "" || title == entry.URL { |
| 115 | - | 101 | + | // Use meaningful fallback for untitled pages | |
| 102 | + | if parsedURL != nil && parsedURL.Path != "" && parsedURL.Path != "/" { | |||
| 103 | + | title = fmt.Sprintf("[%s%s]", domain, parsedURL.Path) | |||
| 104 | + | } else { | |||
| 105 | + | title = fmt.Sprintf("[%s]", domain) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Check if it's a Google search result and format accordingly | |||
| 110 | + | isGoogleSearch := domain == "www.google.com" && strings.Contains(entry.URL, "/search?q=") | |||
| 111 | + | if isGoogleSearch { | |||
| 112 | + | if parsedURL != nil { | |||
| 113 | + | if q := parsedURL.Query().Get("q"); q != "" { | |||
| 114 | + | title = fmt.Sprintf("Google: \"%s\"", q) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Format: "Title | domain.com | full-url" | |||
| 120 | + | // Using pipe separator for rofi/dmenu compatibility | |||
| 121 | + | display := fmt.Sprintf("%s | %s | %s", | |||
| 122 | + | truncateString(title, 50), | |||
| 123 | + | domain, | |||
| 124 | + | truncateString(entry.URL, 70)) | |||
| 125 | + | ||||
| 126 | + | // Get favicon URL from cached entry | |||
| 127 | + | faviconURL := match.Entry.FaviconURL | |||
| 128 | + | ||||
| 116 | options = append(options, DmenuOption{ | 129 | options = append(options, DmenuOption{ | ||
| 117 | - | Display: fmt.Sprintf("🕒 %s", display), | 130 | + | Display: display, |
| 118 | Value: entry.URL, | 131 | Value: entry.URL, | ||
| 119 | Type: historyType, | 132 | Type: historyType, | ||
| 120 | Description: entry.URL, | 133 | Description: entry.URL, | ||
| 134 | + | FaviconURL: faviconURL, | |||
| 121 | }) | 135 | }) | ||
| 122 | } | 136 | } | ||
| 123 | 137 | ||||
| 124 | - | // Sort options: input first, then shortcuts, then history (already sorted by cache) | 138 | + | // No sorting needed - history entries are already sorted by relevance from cache |
| 125 | - | sort.Slice(options, func(i, j int) bool { | |||
| 126 | - | if options[i].Type != options[j].Type { | |||
| 127 | - | // Order: input, shortcut, history | |||
| 128 | - | typeOrder := map[string]int{"input": 0, "shortcut": 1, historyType: 2} //nolint:mnd | |||
| 129 | - | return typeOrder[options[i].Type] < typeOrder[options[j].Type] | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | if options[i].Type == historyType && options[j].Type == historyType { | |||
| 133 | - | // History already sorted by cache relevance score | |||
| 134 | - | return i < j | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | return options[i].Display < options[j].Display | |||
| 138 | - | }) | |||
| 139 | 139 | ||||
| 140 | - | // Output options to stdout | 140 | + | // Output options to stdout with icon specifications |
| 141 | for _, option := range options { | 141 | for _, option := range options { | ||
| 142 | - | fmt.Println(option.Display) | 142 | + | iconName := getIconName(option, faviconCache) |
| 143 | + | if iconName != "" { | |||
| 144 | + | fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName) | |||
| 145 | + | } else { | |||
| 146 | + | fmt.Println(option.Display) | |||
| 147 | + | } | |||
| 143 | } | 148 | } | ||
| 144 | 149 | ||||
| 145 | // Trigger background cache refresh for next time if needed | 150 | // Trigger background cache refresh for next time if needed | ||
| @@ -148,10 +153,17 @@ func generateOptions(cli *CLI) error { | |||||
| 148 | return nil | 153 | return nil | ||
| 149 | } | 154 | } | ||
| 150 | 155 | ||||
| 151 | - | // generateOptionsFallback is the old method used as a fallback if cache fails | 156 | + | // generateOptionsFallback is the legacy method used as a fallback if fast cache fails |
| 152 | func generateOptionsFallback(cli *CLI) error { | 157 | func generateOptionsFallback(cli *CLI) error { | ||
| 153 | ctx := context.Background() | 158 | ctx := context.Background() | ||
| 154 | 159 | ||||
| 160 | + | // Initialize favicon cache | |||
| 161 | + | faviconCache, err := cache.NewFaviconCache() | |||
| 162 | + | if err != nil { | |||
| 163 | + | // Continue without favicon cache if it fails | |||
| 164 | + | faviconCache = nil | |||
| 165 | + | } | |||
| 166 | + | ||||
| 155 | const maxHistoryEntries = 50 | 167 | const maxHistoryEntries = 50 | ||
| 156 | // Get history entries | 168 | // Get history entries | ||
| 157 | history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries) | 169 | history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries) | ||
| @@ -226,9 +238,14 @@ func generateOptionsFallback(cli *CLI) error { | |||||
| 226 | return options[i].Display < options[j].Display | 238 | return options[i].Display < options[j].Display | ||
| 227 | }) | 239 | }) | ||
| 228 | 240 | ||||
| 229 | - | // Output options to stdout | 241 | + | // Output options to stdout with icon specifications |
| 230 | for _, option := range options { | 242 | for _, option := range options { | ||
| 231 | - | fmt.Println(option.Display) | 243 | + | iconName := getIconName(option, faviconCache) |
| 244 | + | if iconName != "" { | |||
| 245 | + | fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName) | |||
| 246 | + | } else { | |||
| 247 | + | fmt.Println(option.Display) | |||
| 248 | + | } | |||
| 232 | } | 249 | } | ||
| 233 | 250 | ||||
| 234 | return nil | 251 | return nil | ||
| @@ -246,42 +263,65 @@ func handleSelection(cli *CLI) error { | |||||
| 246 | return fmt.Errorf("empty selection") | 263 | return fmt.Errorf("empty selection") | ||
| 247 | } | 264 | } | ||
| 248 | 265 | ||||
| 249 | - | // Parse the selection to get the actual input | |||
| 250 | - | input := parseSelection(selection) | |||
| 251 | 266 | ||||
| 252 | - | // If it's just the input prompt, read another line for the actual input | 267 | + | // Parse the selection to get the actual URL from history entry |
| 253 | - | if input == "" || input == "Enter URL or search query..." { | 268 | + | input := parseSelection(selection) |
| 254 | - | fmt.Print("Enter URL or search query: ") | |||
| 255 | - | if !scanner.Scan() { | |||
| 256 | - | return fmt.Errorf("no input received") | |||
| 257 | - | } | |||
| 258 | - | input = strings.TrimSpace(scanner.Text()) | |||
| 259 | - | } | |||
| 260 | 269 | ||||
| 261 | if input == "" { | 270 | if input == "" { | ||
| 262 | return fmt.Errorf("empty input") | 271 | return fmt.Errorf("empty input") | ||
| 263 | } | 272 | } | ||
| 264 | 273 | ||||
| 265 | // Browse the selected/entered URL | 274 | // Browse the selected/entered URL | ||
| 266 | - | return browse(cli, input) | 275 | + | err := browse(cli, input) |
| 276 | + | ||||
| 277 | + | // After successful browse, invalidate cache so next dmenu shows updated order | |||
| 278 | + | if err == nil { | |||
| 279 | + | ctx := context.Background() | |||
| 280 | + | cacheConfig := cache.DefaultCacheConfig() | |||
| 281 | + | cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig) | |||
| 282 | + | cacheManager.InvalidateAndRefresh(ctx) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | return err | |||
| 267 | } | 286 | } | ||
| 268 | 287 | ||||
| 288 | + | ||||
| 269 | // DmenuOption represents a selectable option in dmenu mode | 289 | // DmenuOption represents a selectable option in dmenu mode | ||
| 270 | type DmenuOption struct { | 290 | type DmenuOption struct { | ||
| 271 | Display string | 291 | Display string | ||
| 272 | Value string | 292 | Value string | ||
| 273 | Type string // "input", "shortcut", "history" | 293 | Type string // "input", "shortcut", "history" | ||
| 274 | Description string | 294 | Description string | ||
| 295 | + | FaviconURL string // URL to favicon for this entry | |||
| 275 | } | 296 | } | ||
| 276 | 297 | ||||
| 277 | - | // parseSelection extracts the actual URL/query from a dmenu selection | 298 | + | // parseSelection extracts the actual URL from a dmenu selection |
| 278 | func parseSelection(selection string) string { | 299 | func parseSelection(selection string) string { | ||
| 279 | - | // Remove emoji prefixes and clean up the selection | |||
| 280 | selection = strings.TrimSpace(selection) | 300 | selection = strings.TrimSpace(selection) | ||
| 281 | 301 | ||||
| 282 | - | // Handle different option types | 302 | + | // Strip icon protocol if present (format: "text\0icon\x1ficonname") |
| 303 | + | if iconIndex := strings.Index(selection, "\x00icon\x1f"); iconIndex > 0 { | |||
| 304 | + | selection = selection[:iconIndex] | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // 1. Handle new pipe-separated format: "Title | domain.com | full-url" | |||
| 308 | + | if strings.Contains(selection, " | ") { | |||
| 309 | + | parts := strings.Split(selection, " | ") | |||
| 310 | + | if len(parts) >= 3 { | |||
| 311 | + | // The URL is the last part (third field) | |||
| 312 | + | return strings.TrimSpace(parts[len(parts)-1]) | |||
| 313 | + | } else if len(parts) == 2 { | |||
| 314 | + | // Might be "title | url" format, check if second part looks like URL | |||
| 315 | + | lastPart := strings.TrimSpace(parts[1]) | |||
| 316 | + | if strings.Contains(lastPart, "://") || strings.Contains(lastPart, ".") { | |||
| 317 | + | return lastPart | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // Legacy format handling for backward compatibility | |||
| 283 | if strings.HasPrefix(selection, "🌐 ") { | 323 | if strings.HasPrefix(selection, "🌐 ") { | ||
| 284 | - | // Input option selected | 324 | + | // Input option selected (shouldn't happen with new format but just in case) |
| 285 | return "" | 325 | return "" | ||
| 286 | } | 326 | } | ||
| 287 | 327 | ||||
| @@ -294,15 +334,39 @@ func parseSelection(selection string) string { | |||||
| 294 | } | 334 | } | ||
| 295 | 335 | ||||
| 296 | if strings.HasPrefix(selection, "🕒 ") { | 336 | if strings.HasPrefix(selection, "🕒 ") { | ||
| 297 | - | // History entry selected - this is the display title, need to extract URL | 337 | + | // History entry selected - remove emoji prefix |
| 298 | - | // For now, we'll assume the display is the URL or close enough | 338 | + | selection = strings.TrimPrefix(selection, "🕒 ") |
| 299 | - | return strings.TrimPrefix(selection, "🕒 ") | |||
| 300 | } | 339 | } | ||
| 301 | 340 | ||||
| 302 | - | // Fallback: return the selection as-is | 341 | + | // If no pipes and looks like a URL, return as-is |
| 342 | + | if strings.Contains(selection, "://") { | |||
| 343 | + | return selection | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // Fallback: treat as search query | |||
| 303 | return selection | 347 | return selection | ||
| 304 | } | 348 | } | ||
| 305 | 349 | ||||
| 350 | + | // getIconName determines the appropriate icon name for a dmenu option | |||
| 351 | + | func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string { | |||
| 352 | + | if option.Type != historyType { | |||
| 353 | + | return "" | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | // Only use cached favicons - no system theme fallbacks for consistent sizing | |||
| 357 | + | if option.FaviconURL != "" && faviconCache != nil { | |||
| 358 | + | cachedPath := faviconCache.GetCachedPath(option.FaviconURL) | |||
| 359 | + | if cachedPath != "" { | |||
| 360 | + | return cachedPath | |||
| 361 | + | } | |||
| 362 | + | // Start async download for next time | |||
| 363 | + | faviconCache.CacheAsync(option.FaviconURL) | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | // No favicon available - return empty string to show no icon | |||
| 367 | + | return "" | |||
| 368 | + | } | |||
| 369 | + | ||||
| 306 | // truncateString truncates a string to the specified length with ellipsis | 370 | // truncateString truncates a string to the specified length with ellipsis | ||
| 307 | func truncateString(s string, maxLen int) string { | 371 | func truncateString(s string, maxLen int) string { | ||
| 308 | if len(s) <= maxLen { | 372 | if len(s) <= maxLen { | ||
M internal/cli/root.go +26 -35
| @@ -8,8 +8,11 @@ import ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/dumber/internal/cache" | |
| 11 | 12 | "github.com/bnema/dumber/internal/config" | |
| 12 | 13 | "github.com/bnema/dumber/internal/db" | |
| 14 | + | "github.com/bnema/dumber/internal/migrations" | |
| 15 | + | "github.com/bnema/dumber/services" | |
| 13 | 16 | ||
| 14 | 17 | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql | |
| 15 | 18 | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite for cross-platform compatibility | |
| @@ -23,9 +26,10 @@ const ( | |||
| 23 | 26 | ||
| 24 | 27 | // CLI holds the database connection, queries, and configuration for the CLI commands | |
| 25 | 28 | type CLI struct { | |
| 26 | - | DB *sql.DB | |
| 27 | - | Queries *db.Queries | |
| 28 | - | Config *config.Config | |
| 29 | + | DB *sql.DB | |
| 30 | + | Queries *db.Queries | |
| 31 | + | Config *config.Config | |
| 32 | + | ParserService *services.ParserService | |
| 29 | 33 | } | |
| 30 | 34 | ||
| 31 | 35 | // NewCLI creates a new CLI instance with database connection and configuration | |
| @@ -71,10 +75,21 @@ func NewCLI() (*CLI, error) { | |||
| 71 | 75 | return nil, fmt.Errorf("failed to initialize database: %w", err) | |
| 72 | 76 | } | |
| 73 | 77 | ||
| 78 | + | queries := db.New(database) | |
| 79 | + | parserService := services.NewParserService(cfg, queries) | |
| 80 | + | ||
| 81 | + | // Clean old cached favicons on startup (async, non-blocking) | |
| 82 | + | go func() { | |
| 83 | + | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |
| 84 | + | faviconCache.CleanOld() | |
| 85 | + | } | |
| 86 | + | }() | |
| 87 | + | ||
| 74 | 88 | return &CLI{ | |
| 75 | - | DB: database, | |
| 76 | - | Queries: db.New(database), | |
| 77 | - | Config: cfg, | |
| 89 | + | DB: database, | |
| 90 | + | Queries: queries, | |
| 91 | + | Config: cfg, | |
| 92 | + | ParserService: parserService, | |
| 78 | 93 | }, nil | |
| 79 | 94 | } | |
| 80 | 95 | ||
| @@ -177,38 +192,14 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |||
| 177 | 192 | } | |
| 178 | 193 | ||
| 179 | 194 | ||
| 180 | - | // initializeDatabase creates the database schema if it doesn't exist | |
| 195 | + | // initializeDatabase runs embedded migrations and ensures database is up to date | |
| 181 | 196 | func initializeDatabase(db *sql.DB, cfg *config.Config) error { | |
| 182 | - | schema := ` | |
| 183 | - | -- History tracking for visited URLs | |
| 184 | - | CREATE TABLE IF NOT EXISTS history ( | |
| 185 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 186 | - | url TEXT NOT NULL UNIQUE, | |
| 187 | - | title TEXT, | |
| 188 | - | visit_count INTEGER DEFAULT 1, | |
| 189 | - | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, | |
| 190 | - | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| 191 | - | ); | |
| 192 | - | ||
| 193 | - | CREATE INDEX IF NOT EXISTS idx_history_url ON history(url); | |
| 194 | - | CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited); | |
| 195 | - | ||
| 196 | - | -- URL shortcuts configuration | |
| 197 | - | CREATE TABLE IF NOT EXISTS shortcuts ( | |
| 198 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 199 | - | shortcut TEXT NOT NULL UNIQUE, | |
| 200 | - | url_template TEXT NOT NULL, | |
| 201 | - | description TEXT, | |
| 202 | - | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| 203 | - | ); | |
| 204 | - | ` | |
| 205 | - | ||
| 206 | - | // Execute schema creation | |
| 207 | - | if _, err := db.Exec(schema); err != nil { | |
| 208 | - | return err | |
| 197 | + | // Run embedded migrations - this will create all tables and apply any new migrations | |
| 198 | + | if err := migrations.RunEmbeddedMigrations(db); err != nil { | |
| 199 | + | return fmt.Errorf("failed to run database migrations: %w", err) | |
| 209 | 200 | } | |
| 210 | 201 | ||
| 211 | - | // Insert configured shortcuts | |
| 202 | + | // Insert configured shortcuts (these are additive, won't override existing) | |
| 212 | 203 | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | |
| 213 | 204 | _, err := db.Exec( | |
| 214 | 205 | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | |
| @@ -8,8 +8,11 @@ import ( | |||||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | 10 | ||||
| 11 | + | "github.com/bnema/dumber/internal/cache" | |||
| 11 | "github.com/bnema/dumber/internal/config" | 12 | "github.com/bnema/dumber/internal/config" | ||
| 12 | "github.com/bnema/dumber/internal/db" | 13 | "github.com/bnema/dumber/internal/db" | ||
| 14 | + | "github.com/bnema/dumber/internal/migrations" | |||
| 15 | + | "github.com/bnema/dumber/services" | |||
| 13 | 16 | ||||
| 14 | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql | 17 | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql | ||
| 15 | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite for cross-platform compatibility | 18 | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite for cross-platform compatibility | ||
| @@ -23,9 +26,10 @@ const ( | |||||
| 23 | 26 | ||||
| 24 | // CLI holds the database connection, queries, and configuration for the CLI commands | 27 | // CLI holds the database connection, queries, and configuration for the CLI commands | ||
| 25 | type CLI struct { | 28 | type CLI struct { | ||
| 26 | - | DB *sql.DB | 29 | + | DB *sql.DB |
| 27 | - | Queries *db.Queries | 30 | + | Queries *db.Queries |
| 28 | - | Config *config.Config | 31 | + | Config *config.Config |
| 32 | + | ParserService *services.ParserService | |||
| 29 | } | 33 | } | ||
| 30 | 34 | ||||
| 31 | // NewCLI creates a new CLI instance with database connection and configuration | 35 | // NewCLI creates a new CLI instance with database connection and configuration | ||
| @@ -71,10 +75,21 @@ func NewCLI() (*CLI, error) { | |||||
| 71 | return nil, fmt.Errorf("failed to initialize database: %w", err) | 75 | return nil, fmt.Errorf("failed to initialize database: %w", err) | ||
| 72 | } | 76 | } | ||
| 73 | 77 | ||||
| 78 | + | queries := db.New(database) | |||
| 79 | + | parserService := services.NewParserService(cfg, queries) | |||
| 80 | + | ||||
| 81 | + | // Clean old cached favicons on startup (async, non-blocking) | |||
| 82 | + | go func() { | |||
| 83 | + | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |||
| 84 | + | faviconCache.CleanOld() | |||
| 85 | + | } | |||
| 86 | + | }() | |||
| 87 | + | ||||
| 74 | return &CLI{ | 88 | return &CLI{ | ||
| 75 | - | DB: database, | 89 | + | DB: database, |
| 76 | - | Queries: db.New(database), | 90 | + | Queries: queries, |
| 77 | - | Config: cfg, | 91 | + | Config: cfg, |
| 92 | + | ParserService: parserService, | |||
| 78 | }, nil | 93 | }, nil | ||
| 79 | } | 94 | } | ||
| 80 | 95 | ||||
| @@ -177,38 +192,14 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |||||
| 177 | } | 192 | } | ||
| 178 | 193 | ||||
| 179 | 194 | ||||
| 180 | - | // initializeDatabase creates the database schema if it doesn't exist | 195 | + | // initializeDatabase runs embedded migrations and ensures database is up to date |
| 181 | func initializeDatabase(db *sql.DB, cfg *config.Config) error { | 196 | func initializeDatabase(db *sql.DB, cfg *config.Config) error { | ||
| 182 | - | schema := ` | 197 | + | // Run embedded migrations - this will create all tables and apply any new migrations |
| 183 | - | -- History tracking for visited URLs | 198 | + | if err := migrations.RunEmbeddedMigrations(db); err != nil { |
| 184 | - | CREATE TABLE IF NOT EXISTS history ( | 199 | + | return fmt.Errorf("failed to run database migrations: %w", err) |
| 185 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 186 | - | url TEXT NOT NULL UNIQUE, | |||
| 187 | - | title TEXT, | |||
| 188 | - | visit_count INTEGER DEFAULT 1, | |||
| 189 | - | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, | |||
| 190 | - | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 191 | - | ); | |||
| 192 | - | ||||
| 193 | - | CREATE INDEX IF NOT EXISTS idx_history_url ON history(url); | |||
| 194 | - | CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited); | |||
| 195 | - | ||||
| 196 | - | -- URL shortcuts configuration | |||
| 197 | - | CREATE TABLE IF NOT EXISTS shortcuts ( | |||
| 198 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 199 | - | shortcut TEXT NOT NULL UNIQUE, | |||
| 200 | - | url_template TEXT NOT NULL, | |||
| 201 | - | description TEXT, | |||
| 202 | - | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 203 | - | ); | |||
| 204 | - | ` | |||
| 205 | - | ||||
| 206 | - | // Execute schema creation | |||
| 207 | - | if _, err := db.Exec(schema); err != nil { | |||
| 208 | - | return err | |||
| 209 | } | 200 | } | ||
| 210 | 201 | ||||
| 211 | - | // Insert configured shortcuts | 202 | + | // Insert configured shortcuts (these are additive, won't override existing) |
| 212 | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | 203 | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | ||
| 213 | _, err := db.Exec( | 204 | _, err := db.Exec( | ||
| 214 | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | 205 | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | ||
M internal/db/history.sql.go +15 -2
| @@ -26,7 +26,7 @@ func (q *Queries) AddOrUpdateHistory(ctx context.Context, url string, title sql. | |||
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | const GetHistory = `-- name: GetHistory :many | |
| 29 | - | SELECT id, url, title, visit_count, last_visited, created_at | |
| 29 | + | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | |
| 30 | 30 | FROM history | |
| 31 | 31 | ORDER BY last_visited DESC | |
| 32 | 32 | LIMIT ? | |
| @@ -48,6 +48,7 @@ func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error | |||
| 48 | 48 | &i.VisitCount, | |
| 49 | 49 | &i.LastVisited, | |
| 50 | 50 | &i.CreatedAt, | |
| 51 | + | &i.FaviconUrl, | |
| 51 | 52 | ); err != nil { | |
| 52 | 53 | return nil, err | |
| 53 | 54 | } | |
| @@ -98,7 +99,7 @@ func (q *Queries) GetShortcuts(ctx context.Context) ([]Shortcut, error) { | |||
| 98 | 99 | } | |
| 99 | 100 | ||
| 100 | 101 | const SearchHistory = `-- name: SearchHistory :many | |
| 101 | - | SELECT id, url, title, visit_count, last_visited, created_at | |
| 102 | + | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | |
| 102 | 103 | FROM history | |
| 103 | 104 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | |
| 104 | 105 | ORDER BY visit_count DESC, last_visited DESC | |
| @@ -121,6 +122,7 @@ func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, col | |||
| 121 | 122 | &i.VisitCount, | |
| 122 | 123 | &i.LastVisited, | |
| 123 | 124 | &i.CreatedAt, | |
| 125 | + | &i.FaviconUrl, | |
| 124 | 126 | ); err != nil { | |
| 125 | 127 | return nil, err | |
| 126 | 128 | } | |
| @@ -134,3 +136,14 @@ func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, col | |||
| 134 | 136 | } | |
| 135 | 137 | return items, nil | |
| 136 | 138 | } | |
| 139 | + | ||
| 140 | + | const UpdateHistoryFavicon = `-- name: UpdateHistoryFavicon :exec | |
| 141 | + | UPDATE history | |
| 142 | + | SET favicon_url = ? | |
| 143 | + | WHERE url = ? | |
| 144 | + | ` | |
| 145 | + | ||
| 146 | + | func (q *Queries) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error { | |
| 147 | + | _, err := q.db.ExecContext(ctx, UpdateHistoryFavicon, faviconUrl, url) | |
| 148 | + | return err | |
| 149 | + | } | |
| @@ -26,7 +26,7 @@ func (q *Queries) AddOrUpdateHistory(ctx context.Context, url string, title sql. | |||||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | const GetHistory = `-- name: GetHistory :many | 28 | const GetHistory = `-- name: GetHistory :many | ||
| 29 | - | SELECT id, url, title, visit_count, last_visited, created_at | 29 | + | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url |
| 30 | FROM history | 30 | FROM history | ||
| 31 | ORDER BY last_visited DESC | 31 | ORDER BY last_visited DESC | ||
| 32 | LIMIT ? | 32 | LIMIT ? | ||
| @@ -48,6 +48,7 @@ func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error | |||||
| 48 | &i.VisitCount, | 48 | &i.VisitCount, | ||
| 49 | &i.LastVisited, | 49 | &i.LastVisited, | ||
| 50 | &i.CreatedAt, | 50 | &i.CreatedAt, | ||
| 51 | + | &i.FaviconUrl, | |||
| 51 | ); err != nil { | 52 | ); err != nil { | ||
| 52 | return nil, err | 53 | return nil, err | ||
| 53 | } | 54 | } | ||
| @@ -98,7 +99,7 @@ func (q *Queries) GetShortcuts(ctx context.Context) ([]Shortcut, error) { | |||||
| 98 | } | 99 | } | ||
| 99 | 100 | ||||
| 100 | const SearchHistory = `-- name: SearchHistory :many | 101 | const SearchHistory = `-- name: SearchHistory :many | ||
| 101 | - | SELECT id, url, title, visit_count, last_visited, created_at | 102 | + | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url |
| 102 | FROM history | 103 | FROM history | ||
| 103 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | 104 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | ||
| 104 | ORDER BY visit_count DESC, last_visited DESC | 105 | ORDER BY visit_count DESC, last_visited DESC | ||
| @@ -121,6 +122,7 @@ func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, col | |||||
| 121 | &i.VisitCount, | 122 | &i.VisitCount, | ||
| 122 | &i.LastVisited, | 123 | &i.LastVisited, | ||
| 123 | &i.CreatedAt, | 124 | &i.CreatedAt, | ||
| 125 | + | &i.FaviconUrl, | |||
| 124 | ); err != nil { | 126 | ); err != nil { | ||
| 125 | return nil, err | 127 | return nil, err | ||
| 126 | } | 128 | } | ||
| @@ -134,3 +136,14 @@ func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, col | |||||
| 134 | } | 136 | } | ||
| 135 | return items, nil | 137 | return items, nil | ||
| 136 | } | 138 | } | ||
| 139 | + | ||||
| 140 | + | const UpdateHistoryFavicon = `-- name: UpdateHistoryFavicon :exec | |||
| 141 | + | UPDATE history | |||
| 142 | + | SET favicon_url = ? | |||
| 143 | + | WHERE url = ? | |||
| 144 | + | ` | |||
| 145 | + | ||||
| 146 | + | func (q *Queries) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error { | |||
| 147 | + | _, err := q.db.ExecContext(ctx, UpdateHistoryFavicon, faviconUrl, url) | |||
| 148 | + | return err | |||
| 149 | + | } | |||
M internal/db/interfaces.go +1 -0
| @@ -19,6 +19,7 @@ type HistoryQuerier interface { | |||
| 19 | 19 | GetHistory(ctx context.Context, limit int64) ([]History, error) | |
| 20 | 20 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | |
| 21 | 21 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | |
| 22 | + | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | // ShortcutQuerier defines the interface for shortcut-related database operations | |
| @@ -19,6 +19,7 @@ type HistoryQuerier interface { | |||||
| 19 | GetHistory(ctx context.Context, limit int64) ([]History, error) | 19 | GetHistory(ctx context.Context, limit int64) ([]History, error) | ||
| 20 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | 20 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | ||
| 21 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | 21 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | ||
| 22 | + | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | |||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | // ShortcutQuerier defines the interface for shortcut-related database operations | 25 | // ShortcutQuerier defines the interface for shortcut-related database operations | ||
M internal/db/models.go +1 -0
| @@ -15,6 +15,7 @@ type History struct { | |||
| 15 | 15 | VisitCount sql.NullInt64 `json:"visit_count"` | |
| 16 | 16 | LastVisited sql.NullTime `json:"last_visited"` | |
| 17 | 17 | CreatedAt sql.NullTime `json:"created_at"` | |
| 18 | + | FaviconUrl sql.NullString `json:"favicon_url"` | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | type Shortcut struct { | |
| @@ -15,6 +15,7 @@ type History struct { | |||||
| 15 | VisitCount sql.NullInt64 `json:"visit_count"` | 15 | VisitCount sql.NullInt64 `json:"visit_count"` | ||
| 16 | LastVisited sql.NullTime `json:"last_visited"` | 16 | LastVisited sql.NullTime `json:"last_visited"` | ||
| 17 | CreatedAt sql.NullTime `json:"created_at"` | 17 | CreatedAt sql.NullTime `json:"created_at"` | ||
| 18 | + | FaviconUrl sql.NullString `json:"favicon_url"` | |||
| 18 | } | 19 | } | ||
| 19 | 20 | ||||
| 20 | type Shortcut struct { | 21 | type Shortcut struct { | ||
M internal/db/querier.go +1 -0
| @@ -26,6 +26,7 @@ type Querier interface { | |||
| 26 | 26 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | |
| 27 | 27 | // Set or update zoom level for a domain with validation | |
| 28 | 28 | SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error | |
| 29 | + | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | var _ Querier = (*Queries)(nil) | |
| @@ -26,6 +26,7 @@ type Querier interface { | |||||
| 26 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | 26 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | ||
| 27 | // Set or update zoom level for a domain with validation | 27 | // Set or update zoom level for a domain with validation | ||
| 28 | SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error | 28 | SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error | ||
| 29 | + | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | |||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 31 | var _ Querier = (*Queries)(nil) | 32 | var _ Querier = (*Queries)(nil) | ||
M internal/db/queries/history.sql +7 -2
| @@ -1,5 +1,5 @@ | |||
| 1 | 1 | -- name: GetHistory :many | |
| 2 | - | SELECT id, url, title, visit_count, last_visited, created_at | |
| 2 | + | SELECT * | |
| 3 | 3 | FROM history | |
| 4 | 4 | ORDER BY last_visited DESC | |
| 5 | 5 | LIMIT ?; | |
| @@ -14,12 +14,17 @@ DO UPDATE SET | |||
| 14 | 14 | title = EXCLUDED.title; | |
| 15 | 15 | ||
| 16 | 16 | -- name: SearchHistory :many | |
| 17 | - | SELECT id, url, title, visit_count, last_visited, created_at | |
| 17 | + | SELECT * | |
| 18 | 18 | FROM history | |
| 19 | 19 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | |
| 20 | 20 | ORDER BY visit_count DESC, last_visited DESC | |
| 21 | 21 | LIMIT ?; | |
| 22 | 22 | ||
| 23 | + | -- name: UpdateHistoryFavicon :exec | |
| 24 | + | UPDATE history | |
| 25 | + | SET favicon_url = ? | |
| 26 | + | WHERE url = ?; | |
| 27 | + | ||
| 23 | 28 | -- name: GetShortcuts :many | |
| 24 | 29 | SELECT id, shortcut, url_template, description, created_at | |
| 25 | 30 | FROM shortcuts | |
| @@ -1,5 +1,5 @@ | |||||
| 1 | -- name: GetHistory :many | 1 | -- name: GetHistory :many | ||
| 2 | - | SELECT id, url, title, visit_count, last_visited, created_at | 2 | + | SELECT * |
| 3 | FROM history | 3 | FROM history | ||
| 4 | ORDER BY last_visited DESC | 4 | ORDER BY last_visited DESC | ||
| 5 | LIMIT ?; | 5 | LIMIT ?; | ||
| @@ -14,12 +14,17 @@ DO UPDATE SET | |||||
| 14 | title = EXCLUDED.title; | 14 | title = EXCLUDED.title; | ||
| 15 | 15 | ||||
| 16 | -- name: SearchHistory :many | 16 | -- name: SearchHistory :many | ||
| 17 | - | SELECT id, url, title, visit_count, last_visited, created_at | 17 | + | SELECT * |
| 18 | FROM history | 18 | FROM history | ||
| 19 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | 19 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | ||
| 20 | ORDER BY visit_count DESC, last_visited DESC | 20 | ORDER BY visit_count DESC, last_visited DESC | ||
| 21 | LIMIT ?; | 21 | LIMIT ?; | ||
| 22 | 22 | ||||
| 23 | + | -- name: UpdateHistoryFavicon :exec | |||
| 24 | + | UPDATE history | |||
| 25 | + | SET favicon_url = ? | |||
| 26 | + | WHERE url = ?; | |||
| 27 | + | ||||
| 23 | -- name: GetShortcuts :many | 28 | -- name: GetShortcuts :many | ||
| 24 | SELECT id, shortcut, url_template, description, created_at | 29 | SELECT id, shortcut, url_template, description, created_at | ||
| 25 | FROM shortcuts | 30 | FROM shortcuts | ||
A internal/migrations/001_initial.sql +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | -- Initial database schema for dumber | |
| 2 | + | -- History tracking for visited URLs | |
| 3 | + | ||
| 4 | + | CREATE TABLE IF NOT EXISTS history ( | |
| 5 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 6 | + | url TEXT NOT NULL UNIQUE, | |
| 7 | + | title TEXT, | |
| 8 | + | visit_count INTEGER DEFAULT 1, | |
| 9 | + | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, | |
| 10 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| 11 | + | ); | |
| 12 | + | ||
| 13 | + | CREATE INDEX IF NOT EXISTS idx_history_url ON history(url); | |
| 14 | + | CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited); | |
| 15 | + | ||
| 16 | + | -- URL shortcuts configuration | |
| 17 | + | CREATE TABLE IF NOT EXISTS shortcuts ( | |
| 18 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 19 | + | shortcut TEXT NOT NULL UNIQUE, | |
| 20 | + | url_template TEXT NOT NULL, | |
| 21 | + | description TEXT, | |
| 22 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| 23 | + | ); | |
| 24 | + | ||
| 25 | + | INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES | |
| 26 | + | ('g', 'https://www.google.com/search?q=%s', 'Google search'), | |
| 27 | + | ('gh', 'https://github.com/search?q=%s', 'GitHub search'), | |
| 28 | + | ('yt', 'https://www.youtube.com/results?search_query=%s', 'YouTube search'), | |
| 29 | + | ('w', 'https://en.wikipedia.org/wiki/%s', 'Wikipedia search');⏎̸ | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | -- Initial database schema for dumber | |||
| 2 | + | -- History tracking for visited URLs | |||
| 3 | + | ||||
| 4 | + | CREATE TABLE IF NOT EXISTS history ( | |||
| 5 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 6 | + | url TEXT NOT NULL UNIQUE, | |||
| 7 | + | title TEXT, | |||
| 8 | + | visit_count INTEGER DEFAULT 1, | |||
| 9 | + | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, | |||
| 10 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 11 | + | ); | |||
| 12 | + | ||||
| 13 | + | CREATE INDEX IF NOT EXISTS idx_history_url ON history(url); | |||
| 14 | + | CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited); | |||
| 15 | + | ||||
| 16 | + | -- URL shortcuts configuration | |||
| 17 | + | CREATE TABLE IF NOT EXISTS shortcuts ( | |||
| 18 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 19 | + | shortcut TEXT NOT NULL UNIQUE, | |||
| 20 | + | url_template TEXT NOT NULL, | |||
| 21 | + | description TEXT, | |||
| 22 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 23 | + | ); | |||
| 24 | + | ||||
| 25 | + | INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES | |||
| 26 | + | ('g', 'https://www.google.com/search?q=%s', 'Google search'), | |||
| 27 | + | ('gh', 'https://github.com/search?q=%s', 'GitHub search'), | |||
| 28 | + | ('yt', 'https://www.youtube.com/results?search_query=%s', 'YouTube search'), | |||
| 29 | + | ('w', 'https://en.wikipedia.org/wiki/%s', 'Wikipedia search');⏎̸ | |||
A internal/migrations/002_zoom_persistence.sql +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | -- Add zoom persistence table | |
| 2 | + | -- Store zoom levels per URL for restoration between sessions | |
| 3 | + | ||
| 4 | + | CREATE TABLE IF NOT EXISTS zoom_settings ( | |
| 5 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 6 | + | url TEXT NOT NULL UNIQUE, | |
| 7 | + | zoom_level REAL NOT NULL DEFAULT 1.0, | |
| 8 | + | last_updated DATETIME DEFAULT CURRENT_TIMESTAMP | |
| 9 | + | ); | |
| 10 | + | ||
| 11 | + | CREATE INDEX idx_zoom_settings_url ON zoom_settings(url);⏎̸ | |
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | -- Add zoom persistence table | |||
| 2 | + | -- Store zoom levels per URL for restoration between sessions | |||
| 3 | + | ||||
| 4 | + | CREATE TABLE IF NOT EXISTS zoom_settings ( | |||
| 5 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 6 | + | url TEXT NOT NULL UNIQUE, | |||
| 7 | + | zoom_level REAL NOT NULL DEFAULT 1.0, | |||
| 8 | + | last_updated DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 9 | + | ); | |||
| 10 | + | ||||
| 11 | + | CREATE INDEX idx_zoom_settings_url ON zoom_settings(url);⏎̸ | |||
A internal/migrations/003_add_zoom_levels.sql +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | -- Add zoom_levels table to replace zoom_settings with spec requirements | |
| 2 | + | -- Store zoom levels per domain (not full URL) with Firefox zoom constraints | |
| 3 | + | ||
| 4 | + | -- Drop existing zoom_settings table | |
| 5 | + | DROP TABLE IF EXISTS zoom_settings; | |
| 6 | + | ||
| 7 | + | -- Create new zoom_levels table following spec requirements | |
| 8 | + | CREATE TABLE IF NOT EXISTS zoom_levels ( | |
| 9 | + | domain TEXT PRIMARY KEY, | |
| 10 | + | zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.3 AND zoom_factor <= 5.0), | |
| 11 | + | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |
| 12 | + | ); | |
| 13 | + | ||
| 14 | + | -- Create index for performance on updated_at for cleanup operations | |
| 15 | + | CREATE INDEX IF NOT EXISTS idx_zoom_levels_updated_at ON zoom_levels(updated_at);⏎̸ | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | -- Add zoom_levels table to replace zoom_settings with spec requirements | |||
| 2 | + | -- Store zoom levels per domain (not full URL) with Firefox zoom constraints | |||
| 3 | + | ||||
| 4 | + | -- Drop existing zoom_settings table | |||
| 5 | + | DROP TABLE IF EXISTS zoom_settings; | |||
| 6 | + | ||||
| 7 | + | -- Create new zoom_levels table following spec requirements | |||
| 8 | + | CREATE TABLE IF NOT EXISTS zoom_levels ( | |||
| 9 | + | domain TEXT PRIMARY KEY, | |||
| 10 | + | zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.3 AND zoom_factor <= 5.0), | |||
| 11 | + | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |||
| 12 | + | ); | |||
| 13 | + | ||||
| 14 | + | -- Create index for performance on updated_at for cleanup operations | |||
| 15 | + | CREATE INDEX IF NOT EXISTS idx_zoom_levels_updated_at ON zoom_levels(updated_at);⏎̸ | |||
A internal/migrations/004_add_favicon_url.sql +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | -- Add favicon_url column to history table | |
| 2 | + | -- Store favicon URLs for better UI display in dmenu | |
| 3 | + | ||
| 4 | + | ALTER TABLE history ADD COLUMN favicon_url TEXT DEFAULT NULL; | |
| 5 | + | CREATE INDEX IF NOT EXISTS idx_history_favicon ON history(favicon_url);⏎̸ | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | -- Add favicon_url column to history table | |||
| 2 | + | -- Store favicon URLs for better UI display in dmenu | |||
| 3 | + | ||||
| 4 | + | ALTER TABLE history ADD COLUMN favicon_url TEXT DEFAULT NULL; | |||
| 5 | + | CREATE INDEX IF NOT EXISTS idx_history_favicon ON history(favicon_url);⏎̸ | |||
A internal/migrations/migrations.go +190 -0
| @@ -0,0 +1,190 @@ | |||
| 1 | + | package migrations | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "database/sql" | |
| 5 | + | "embed" | |
| 6 | + | "fmt" | |
| 7 | + | "log" | |
| 8 | + | "sort" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | //go:embed *.sql | |
| 14 | + | var migrationFiles embed.FS | |
| 15 | + | ||
| 16 | + | // Migration represents a single database migration | |
| 17 | + | type Migration struct { | |
| 18 | + | Version int | |
| 19 | + | Name string | |
| 20 | + | SQL string | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // GetMigrations returns all embedded migrations sorted by version | |
| 24 | + | func GetMigrations() ([]Migration, error) { | |
| 25 | + | entries, err := migrationFiles.ReadDir(".") | |
| 26 | + | if err != nil { | |
| 27 | + | return nil, fmt.Errorf("failed to read embedded migrations directory: %w", err) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | var migrations []Migration | |
| 31 | + | for _, entry := range entries { | |
| 32 | + | if !strings.HasSuffix(entry.Name(), ".sql") { | |
| 33 | + | continue | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Parse version from filename (e.g., "001_initial.sql" -> version 1) | |
| 37 | + | parts := strings.SplitN(entry.Name(), "_", 2) | |
| 38 | + | if len(parts) != 2 { | |
| 39 | + | log.Printf("Warning: skipping migration file with invalid name format: %s", entry.Name()) | |
| 40 | + | continue | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | versionStr := parts[0] | |
| 44 | + | version, err := strconv.Atoi(versionStr) | |
| 45 | + | if err != nil { | |
| 46 | + | log.Printf("Warning: skipping migration file with invalid version: %s", entry.Name()) | |
| 47 | + | continue | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Extract name without extension | |
| 51 | + | name := strings.TrimSuffix(parts[1], ".sql") | |
| 52 | + | ||
| 53 | + | // Read SQL content | |
| 54 | + | content, err := migrationFiles.ReadFile(entry.Name()) | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, fmt.Errorf("failed to read migration file %s: %w", entry.Name(), err) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | migrations = append(migrations, Migration{ | |
| 60 | + | Version: version, | |
| 61 | + | Name: name, | |
| 62 | + | SQL: string(content), | |
| 63 | + | }) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Sort by version | |
| 67 | + | sort.Slice(migrations, func(i, j int) bool { | |
| 68 | + | return migrations[i].Version < migrations[j].Version | |
| 69 | + | }) | |
| 70 | + | ||
| 71 | + | return migrations, nil | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // RunEmbeddedMigrations applies all pending embedded migrations to the database | |
| 75 | + | func RunEmbeddedMigrations(db *sql.DB) error { | |
| 76 | + | // Create migrations tracking table if it doesn't exist | |
| 77 | + | if err := createMigrationsTable(db); err != nil { | |
| 78 | + | return fmt.Errorf("failed to create migrations table: %w", err) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | migrations, err := GetMigrations() | |
| 82 | + | if err != nil { | |
| 83 | + | return fmt.Errorf("failed to load migrations: %w", err) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | for _, migration := range migrations { | |
| 87 | + | if err := applyMigration(db, migration); err != nil { | |
| 88 | + | return fmt.Errorf("failed to apply migration %d (%s): %w", migration.Version, migration.Name, err) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | return nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // createMigrationsTable creates the table for tracking applied migrations | |
| 96 | + | func createMigrationsTable(db *sql.DB) error { | |
| 97 | + | schema := ` | |
| 98 | + | CREATE TABLE IF NOT EXISTS schema_migrations ( | |
| 99 | + | version INTEGER PRIMARY KEY, | |
| 100 | + | name TEXT NOT NULL, | |
| 101 | + | applied_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| 102 | + | );` | |
| 103 | + | ||
| 104 | + | _, err := db.Exec(schema) | |
| 105 | + | return err | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // applyMigration applies a single migration if it hasn't been applied yet | |
| 109 | + | func applyMigration(db *sql.DB, migration Migration) error { | |
| 110 | + | // Check if migration has already been applied | |
| 111 | + | var count int | |
| 112 | + | err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", migration.Version).Scan(&count) | |
| 113 | + | if err != nil { | |
| 114 | + | return fmt.Errorf("failed to check migration status: %w", err) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | if count > 0 { | |
| 118 | + | // Migration already applied, skip | |
| 119 | + | return nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | log.Printf("Applying migration %03d: %s", migration.Version, migration.Name) | |
| 123 | + | ||
| 124 | + | // Use transaction for safety | |
| 125 | + | tx, err := db.Begin() | |
| 126 | + | if err != nil { | |
| 127 | + | return fmt.Errorf("failed to begin transaction: %w", err) | |
| 128 | + | } | |
| 129 | + | defer func() { | |
| 130 | + | if err != nil { | |
| 131 | + | tx.Rollback() | |
| 132 | + | } | |
| 133 | + | }() | |
| 134 | + | ||
| 135 | + | // Execute migration SQL | |
| 136 | + | if _, err := tx.Exec(migration.SQL); err != nil { | |
| 137 | + | return fmt.Errorf("failed to execute migration SQL: %w", err) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Record migration as applied | |
| 141 | + | if _, err := tx.Exec( | |
| 142 | + | "INSERT INTO schema_migrations (version, name) VALUES (?, ?)", | |
| 143 | + | migration.Version, migration.Name, | |
| 144 | + | ); err != nil { | |
| 145 | + | return fmt.Errorf("failed to record migration: %w", err) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | if err := tx.Commit(); err != nil { | |
| 149 | + | return fmt.Errorf("failed to commit migration transaction: %w", err) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | log.Printf("Successfully applied migration %03d: %s", migration.Version, migration.Name) | |
| 153 | + | return nil | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // GetAppliedMigrations returns the list of applied migration versions | |
| 157 | + | func GetAppliedMigrations(db *sql.DB) ([]int, error) { | |
| 158 | + | // First check if migrations table exists | |
| 159 | + | var count int | |
| 160 | + | err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'").Scan(&count) | |
| 161 | + | if err != nil { | |
| 162 | + | return nil, fmt.Errorf("failed to check if migrations table exists: %w", err) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | if count == 0 { | |
| 166 | + | // Migrations table doesn't exist, no migrations applied | |
| 167 | + | return []int{}, nil | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version") | |
| 171 | + | if err != nil { | |
| 172 | + | return nil, fmt.Errorf("failed to query applied migrations: %w", err) | |
| 173 | + | } | |
| 174 | + | defer rows.Close() | |
| 175 | + | ||
| 176 | + | var versions []int | |
| 177 | + | for rows.Next() { | |
| 178 | + | var version int | |
| 179 | + | if err := rows.Scan(&version); err != nil { | |
| 180 | + | return nil, fmt.Errorf("failed to scan migration version: %w", err) | |
| 181 | + | } | |
| 182 | + | versions = append(versions, version) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | if err := rows.Err(); err != nil { | |
| 186 | + | return nil, fmt.Errorf("error iterating over migration rows: %w", err) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | return versions, nil | |
| 190 | + | }⏎̸ | |
| @@ -0,0 +1,190 @@ | |||||
| 1 | + | package migrations | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "database/sql" | |||
| 5 | + | "embed" | |||
| 6 | + | "fmt" | |||
| 7 | + | "log" | |||
| 8 | + | "sort" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | //go:embed *.sql | |||
| 14 | + | var migrationFiles embed.FS | |||
| 15 | + | ||||
| 16 | + | // Migration represents a single database migration | |||
| 17 | + | type Migration struct { | |||
| 18 | + | Version int | |||
| 19 | + | Name string | |||
| 20 | + | SQL string | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // GetMigrations returns all embedded migrations sorted by version | |||
| 24 | + | func GetMigrations() ([]Migration, error) { | |||
| 25 | + | entries, err := migrationFiles.ReadDir(".") | |||
| 26 | + | if err != nil { | |||
| 27 | + | return nil, fmt.Errorf("failed to read embedded migrations directory: %w", err) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | var migrations []Migration | |||
| 31 | + | for _, entry := range entries { | |||
| 32 | + | if !strings.HasSuffix(entry.Name(), ".sql") { | |||
| 33 | + | continue | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // Parse version from filename (e.g., "001_initial.sql" -> version 1) | |||
| 37 | + | parts := strings.SplitN(entry.Name(), "_", 2) | |||
| 38 | + | if len(parts) != 2 { | |||
| 39 | + | log.Printf("Warning: skipping migration file with invalid name format: %s", entry.Name()) | |||
| 40 | + | continue | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | versionStr := parts[0] | |||
| 44 | + | version, err := strconv.Atoi(versionStr) | |||
| 45 | + | if err != nil { | |||
| 46 | + | log.Printf("Warning: skipping migration file with invalid version: %s", entry.Name()) | |||
| 47 | + | continue | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Extract name without extension | |||
| 51 | + | name := strings.TrimSuffix(parts[1], ".sql") | |||
| 52 | + | ||||
| 53 | + | // Read SQL content | |||
| 54 | + | content, err := migrationFiles.ReadFile(entry.Name()) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return nil, fmt.Errorf("failed to read migration file %s: %w", entry.Name(), err) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | migrations = append(migrations, Migration{ | |||
| 60 | + | Version: version, | |||
| 61 | + | Name: name, | |||
| 62 | + | SQL: string(content), | |||
| 63 | + | }) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Sort by version | |||
| 67 | + | sort.Slice(migrations, func(i, j int) bool { | |||
| 68 | + | return migrations[i].Version < migrations[j].Version | |||
| 69 | + | }) | |||
| 70 | + | ||||
| 71 | + | return migrations, nil | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // RunEmbeddedMigrations applies all pending embedded migrations to the database | |||
| 75 | + | func RunEmbeddedMigrations(db *sql.DB) error { | |||
| 76 | + | // Create migrations tracking table if it doesn't exist | |||
| 77 | + | if err := createMigrationsTable(db); err != nil { | |||
| 78 | + | return fmt.Errorf("failed to create migrations table: %w", err) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | migrations, err := GetMigrations() | |||
| 82 | + | if err != nil { | |||
| 83 | + | return fmt.Errorf("failed to load migrations: %w", err) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | for _, migration := range migrations { | |||
| 87 | + | if err := applyMigration(db, migration); err != nil { | |||
| 88 | + | return fmt.Errorf("failed to apply migration %d (%s): %w", migration.Version, migration.Name, err) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | return nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // createMigrationsTable creates the table for tracking applied migrations | |||
| 96 | + | func createMigrationsTable(db *sql.DB) error { | |||
| 97 | + | schema := ` | |||
| 98 | + | CREATE TABLE IF NOT EXISTS schema_migrations ( | |||
| 99 | + | version INTEGER PRIMARY KEY, | |||
| 100 | + | name TEXT NOT NULL, | |||
| 101 | + | applied_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 102 | + | );` | |||
| 103 | + | ||||
| 104 | + | _, err := db.Exec(schema) | |||
| 105 | + | return err | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // applyMigration applies a single migration if it hasn't been applied yet | |||
| 109 | + | func applyMigration(db *sql.DB, migration Migration) error { | |||
| 110 | + | // Check if migration has already been applied | |||
| 111 | + | var count int | |||
| 112 | + | err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", migration.Version).Scan(&count) | |||
| 113 | + | if err != nil { | |||
| 114 | + | return fmt.Errorf("failed to check migration status: %w", err) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | if count > 0 { | |||
| 118 | + | // Migration already applied, skip | |||
| 119 | + | return nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | log.Printf("Applying migration %03d: %s", migration.Version, migration.Name) | |||
| 123 | + | ||||
| 124 | + | // Use transaction for safety | |||
| 125 | + | tx, err := db.Begin() | |||
| 126 | + | if err != nil { | |||
| 127 | + | return fmt.Errorf("failed to begin transaction: %w", err) | |||
| 128 | + | } | |||
| 129 | + | defer func() { | |||
| 130 | + | if err != nil { | |||
| 131 | + | tx.Rollback() | |||
| 132 | + | } | |||
| 133 | + | }() | |||
| 134 | + | ||||
| 135 | + | // Execute migration SQL | |||
| 136 | + | if _, err := tx.Exec(migration.SQL); err != nil { | |||
| 137 | + | return fmt.Errorf("failed to execute migration SQL: %w", err) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Record migration as applied | |||
| 141 | + | if _, err := tx.Exec( | |||
| 142 | + | "INSERT INTO schema_migrations (version, name) VALUES (?, ?)", | |||
| 143 | + | migration.Version, migration.Name, | |||
| 144 | + | ); err != nil { | |||
| 145 | + | return fmt.Errorf("failed to record migration: %w", err) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | if err := tx.Commit(); err != nil { | |||
| 149 | + | return fmt.Errorf("failed to commit migration transaction: %w", err) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | log.Printf("Successfully applied migration %03d: %s", migration.Version, migration.Name) | |||
| 153 | + | return nil | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // GetAppliedMigrations returns the list of applied migration versions | |||
| 157 | + | func GetAppliedMigrations(db *sql.DB) ([]int, error) { | |||
| 158 | + | // First check if migrations table exists | |||
| 159 | + | var count int | |||
| 160 | + | err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'").Scan(&count) | |||
| 161 | + | if err != nil { | |||
| 162 | + | return nil, fmt.Errorf("failed to check if migrations table exists: %w", err) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | if count == 0 { | |||
| 166 | + | // Migrations table doesn't exist, no migrations applied | |||
| 167 | + | return []int{}, nil | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version") | |||
| 171 | + | if err != nil { | |||
| 172 | + | return nil, fmt.Errorf("failed to query applied migrations: %w", err) | |||
| 173 | + | } | |||
| 174 | + | defer rows.Close() | |||
| 175 | + | ||||
| 176 | + | var versions []int | |||
| 177 | + | for rows.Next() { | |||
| 178 | + | var version int | |||
| 179 | + | if err := rows.Scan(&version); err != nil { | |||
| 180 | + | return nil, fmt.Errorf("failed to scan migration version: %w", err) | |||
| 181 | + | } | |||
| 182 | + | versions = append(versions, version) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | if err := rows.Err(); err != nil { | |||
| 186 | + | return nil, fmt.Errorf("error iterating over migration rows: %w", err) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | return versions, nil | |||
| 190 | + | }⏎̸ | |||
M internal/parser/parser.go +108 -27
| @@ -3,6 +3,7 @@ package parser | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | neturl "net/url" | |
| 6 | + | "regexp" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -10,59 +11,139 @@ import ( | |||
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | // ParseInput parses user input and returns a ParseResult with URL and metadata. | |
| 13 | - | // | |
| 14 | - | // Learning Behavior: When users search with shortcuts like "g: viper cli" and visit | |
| 15 | - | // results, those URLs are recorded in history. Future searches for "viper cli" will | |
| 16 | - | // find the GitHub page in history via fuzzy matching, creating intelligent learning. | |
| 14 | + | // Now uses the proven CLI parsing logic for consistent behavior. | |
| 17 | 15 | func (p *Parser) ParseInput(input string) (*ParseResult, error) { | |
| 18 | 16 | startTime := time.Now() | |
| 19 | 17 | ||
| 20 | - | // Sanitize input | |
| 21 | - | validator := NewURLValidator() | |
| 22 | - | cleanInput := validator.SanitizeInput(input) | |
| 23 | - | ||
| 24 | - | if cleanInput == "" { | |
| 18 | + | if input == "" { | |
| 25 | 19 | return &ParseResult{ | |
| 26 | 20 | Type: InputTypeFallbackSearch, | |
| 27 | - | URL: p.buildSearchURL("", cleanInput), | |
| 21 | + | URL: "https://www.google.com/search?q=", | |
| 28 | 22 | Query: input, | |
| 29 | 23 | Confidence: 0.0, | |
| 30 | 24 | ProcessingTime: time.Since(startTime), | |
| 31 | 25 | }, nil | |
| 32 | 26 | } | |
| 33 | 27 | ||
| 34 | - | // Determine input type | |
| 35 | - | inputType := validator.GetURLType(cleanInput) | |
| 36 | - | ||
| 37 | - | switch inputType { | |
| 38 | - | case InputTypeDirectURL: | |
| 39 | - | return p.parseDirectURL(cleanInput, input, startTime) | |
| 28 | + | // Use the working CLI parsing logic | |
| 29 | + | finalURL, err := p.parseInputUsingCLILogic(input) | |
| 30 | + | if err != nil { | |
| 31 | + | // If parsing fails, fall back to search | |
| 32 | + | finalURL = fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input)) | |
| 33 | + | } | |
| 40 | 34 | ||
| 41 | - | case InputTypeSearchShortcut: | |
| 42 | - | return p.parseSearchShortcut(cleanInput, input, startTime) | |
| 35 | + | // Determine result type based on the URL | |
| 36 | + | var resultType InputType = InputTypeFallbackSearch | |
| 37 | + | confidence := 0.1 | |
| 43 | 38 | ||
| 44 | - | case InputTypeHistorySearch: | |
| 45 | - | return p.parseHistorySearch(cleanInput, input, startTime) | |
| 39 | + | if strings.Contains(finalURL, "google.com/search") { | |
| 40 | + | resultType = InputTypeFallbackSearch | |
| 41 | + | confidence = 0.1 | |
| 42 | + | } else if strings.HasPrefix(finalURL, "http://") || strings.HasPrefix(finalURL, "https://") { | |
| 43 | + | resultType = InputTypeDirectURL | |
| 44 | + | confidence = 1.0 | |
| 45 | + | } | |
| 46 | 46 | ||
| 47 | - | default: | |
| 48 | - | return p.parseFallbackSearch(cleanInput, input, startTime) | |
| 47 | + | // Check if it was a shortcut | |
| 48 | + | if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") { | |
| 49 | + | shortcut := strings.TrimSpace(input[:idx]) | |
| 50 | + | if _, exists := p.config.SearchShortcuts[shortcut]; exists { | |
| 51 | + | resultType = InputTypeSearchShortcut | |
| 52 | + | confidence = 0.95 | |
| 53 | + | } | |
| 49 | 54 | } | |
| 55 | + | ||
| 56 | + | return &ParseResult{ | |
| 57 | + | Type: resultType, | |
| 58 | + | URL: finalURL, | |
| 59 | + | Query: input, | |
| 60 | + | Confidence: confidence, | |
| 61 | + | ProcessingTime: time.Since(startTime), | |
| 62 | + | }, nil | |
| 50 | 63 | } | |
| 51 | 64 | ||
| 52 | - | // parseDirectURL handles direct URL inputs. | |
| 65 | + | // parseDirectURL handles direct URL inputs using CLI logic. | |
| 53 | 66 | func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | |
| 54 | - | validator := NewURLValidator() | |
| 55 | - | normalizedURL := validator.NormalizeURL(cleanInput) | |
| 67 | + | // Use the working CLI parsing logic instead | |
| 68 | + | finalURL, err := p.parseInputUsingCLILogic(cleanInput) | |
| 69 | + | if err != nil { | |
| 70 | + | return nil, err | |
| 71 | + | } | |
| 56 | 72 | ||
| 57 | 73 | return &ParseResult{ | |
| 58 | 74 | Type: InputTypeDirectURL, | |
| 59 | - | URL: normalizedURL, | |
| 75 | + | URL: finalURL, | |
| 60 | 76 | Query: originalInput, | |
| 61 | - | Confidence: 1.0, // High confidence for direct URLs | |
| 77 | + | Confidence: 1.0, | |
| 62 | 78 | ProcessingTime: time.Since(startTime), | |
| 63 | 79 | }, nil | |
| 64 | 80 | } | |
| 65 | 81 | ||
| 82 | + | // parseInputUsingCLILogic implements the working CLI parsing logic | |
| 83 | + | func (p *Parser) parseInputUsingCLILogic(input string) (string, error) { | |
| 84 | + | input = strings.TrimSpace(input) | |
| 85 | + | ||
| 86 | + | // 1. Check if it's a shortcut (format: "prefix:query") | |
| 87 | + | if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") { | |
| 88 | + | shortcut := strings.TrimSpace(input[:idx]) | |
| 89 | + | query := strings.TrimSpace(input[idx+1:]) | |
| 90 | + | ||
| 91 | + | if query != "" { | |
| 92 | + | // First check configuration-based shortcuts | |
| 93 | + | if shortcutCfg, exists := p.config.SearchShortcuts[shortcut]; exists { | |
| 94 | + | return fmt.Sprintf(shortcutCfg.URL, neturl.QueryEscape(query)), nil | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | return "", fmt.Errorf("unknown shortcut '%s'", shortcut) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // 2. Already a full URL with protocol | |
| 102 | + | if regexp.MustCompile(`^https?://`).MatchString(input) { | |
| 103 | + | return input, nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // 3. File protocol | |
| 107 | + | if strings.HasPrefix(input, "file://") { | |
| 108 | + | return input, nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // 4. Localhost/development URLs | |
| 112 | + | if regexp.MustCompile(`^(localhost|127\.0\.0\.1|0\.0\.0\.0|::1)(:\d+)?(/.*)?$`).MatchString(input) { | |
| 113 | + | return "http://" + input, nil | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // 5. Local network IPs | |
| 117 | + | if regexp.MustCompile(`^(192\.168|10\.|172\.(1[6-9]|2[0-9]|3[01]))\.\d+\.\d+(:\d+)?(/.*)?$`).MatchString(input) { | |
| 118 | + | return "http://" + input, nil | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // 6. Domain with TLD (must have dot and valid TLD) | |
| 122 | + | // More comprehensive TLD pattern for better URL detection | |
| 123 | + | domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(/.*)?$`) | |
| 124 | + | if domainPattern.MatchString(input) { | |
| 125 | + | // Additional check for common TLDs to reduce false positives | |
| 126 | + | commonTLDs := regexp.MustCompile(`\.(com|org|net|edu|gov|io|co|uk|de|fr|jp|cn|in|br|au|ca|ru|nl|it|es|se|no|fi|dk|pl|ch|at|be|cz|gr|il|mx|nz|sg|kr|tw|hk|my|th|vn|id|ph|za|eg|ng|ke|dev|app|xyz|tech|site|online|store|blog|info|biz|name|pro)(/.*)?$`) | |
| 127 | + | if commonTLDs.MatchString(input) { | |
| 128 | + | return "https://" + input, nil | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // If it has a dot but uncommon TLD, still try as URL | |
| 132 | + | return "https://" + input, nil | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // 7. Possible local hostname (single word, might be on local network) | |
| 136 | + | singleHostPattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`) | |
| 137 | + | if singleHostPattern.MatchString(input) && len(input) <= 63 { | |
| 138 | + | // Could be local hostname, try as URL but don't validate | |
| 139 | + | return "http://" + input, nil | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // 8. Everything else is a search query | |
| 143 | + | // This includes: multi-word phrases, questions, special characters, etc. | |
| 144 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input)), nil | |
| 145 | + | } | |
| 146 | + | ||
| 66 | 147 | // parseSearchShortcut handles search shortcut inputs. | |
| 67 | 148 | func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | |
| 68 | 149 | validator := NewURLValidator() | |
| @@ -3,6 +3,7 @@ package parser | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | neturl "net/url" | 5 | neturl "net/url" | ||
| 6 | + | "regexp" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -10,59 +11,139 @@ import ( | |||||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | // ParseInput parses user input and returns a ParseResult with URL and metadata. | 13 | // ParseInput parses user input and returns a ParseResult with URL and metadata. | ||
| 13 | - | // | 14 | + | // Now uses the proven CLI parsing logic for consistent behavior. |
| 14 | - | // Learning Behavior: When users search with shortcuts like "g: viper cli" and visit | |||
| 15 | - | // results, those URLs are recorded in history. Future searches for "viper cli" will | |||
| 16 | - | // find the GitHub page in history via fuzzy matching, creating intelligent learning. | |||
| 17 | func (p *Parser) ParseInput(input string) (*ParseResult, error) { | 15 | func (p *Parser) ParseInput(input string) (*ParseResult, error) { | ||
| 18 | startTime := time.Now() | 16 | startTime := time.Now() | ||
| 19 | 17 | ||||
| 20 | - | // Sanitize input | 18 | + | if input == "" { |
| 21 | - | validator := NewURLValidator() | |||
| 22 | - | cleanInput := validator.SanitizeInput(input) | |||
| 23 | - | ||||
| 24 | - | if cleanInput == "" { | |||
| 25 | return &ParseResult{ | 19 | return &ParseResult{ | ||
| 26 | Type: InputTypeFallbackSearch, | 20 | Type: InputTypeFallbackSearch, | ||
| 27 | - | URL: p.buildSearchURL("", cleanInput), | 21 | + | URL: "https://www.google.com/search?q=", |
| 28 | Query: input, | 22 | Query: input, | ||
| 29 | Confidence: 0.0, | 23 | Confidence: 0.0, | ||
| 30 | ProcessingTime: time.Since(startTime), | 24 | ProcessingTime: time.Since(startTime), | ||
| 31 | }, nil | 25 | }, nil | ||
| 32 | } | 26 | } | ||
| 33 | 27 | ||||
| 34 | - | // Determine input type | 28 | + | // Use the working CLI parsing logic |
| 35 | - | inputType := validator.GetURLType(cleanInput) | 29 | + | finalURL, err := p.parseInputUsingCLILogic(input) |
| 36 | - | 30 | + | if err != nil { | |
| 37 | - | switch inputType { | 31 | + | // If parsing fails, fall back to search |
| 38 | - | case InputTypeDirectURL: | 32 | + | finalURL = fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input)) |
| 39 | - | return p.parseDirectURL(cleanInput, input, startTime) | 33 | + | } |
| 40 | 34 | ||||
| 41 | - | case InputTypeSearchShortcut: | 35 | + | // Determine result type based on the URL |
| 42 | - | return p.parseSearchShortcut(cleanInput, input, startTime) | 36 | + | var resultType InputType = InputTypeFallbackSearch |
| 37 | + | confidence := 0.1 | |||
| 43 | 38 | ||||
| 44 | - | case InputTypeHistorySearch: | 39 | + | if strings.Contains(finalURL, "google.com/search") { |
| 45 | - | return p.parseHistorySearch(cleanInput, input, startTime) | 40 | + | resultType = InputTypeFallbackSearch |
| 41 | + | confidence = 0.1 | |||
| 42 | + | } else if strings.HasPrefix(finalURL, "http://") || strings.HasPrefix(finalURL, "https://") { | |||
| 43 | + | resultType = InputTypeDirectURL | |||
| 44 | + | confidence = 1.0 | |||
| 45 | + | } | |||
| 46 | 46 | ||||
| 47 | - | default: | 47 | + | // Check if it was a shortcut |
| 48 | - | return p.parseFallbackSearch(cleanInput, input, startTime) | 48 | + | if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") { |
| 49 | + | shortcut := strings.TrimSpace(input[:idx]) | |||
| 50 | + | if _, exists := p.config.SearchShortcuts[shortcut]; exists { | |||
| 51 | + | resultType = InputTypeSearchShortcut | |||
| 52 | + | confidence = 0.95 | |||
| 53 | + | } | |||
| 49 | } | 54 | } | ||
| 55 | + | ||||
| 56 | + | return &ParseResult{ | |||
| 57 | + | Type: resultType, | |||
| 58 | + | URL: finalURL, | |||
| 59 | + | Query: input, | |||
| 60 | + | Confidence: confidence, | |||
| 61 | + | ProcessingTime: time.Since(startTime), | |||
| 62 | + | }, nil | |||
| 50 | } | 63 | } | ||
| 51 | 64 | ||||
| 52 | - | // parseDirectURL handles direct URL inputs. | 65 | + | // parseDirectURL handles direct URL inputs using CLI logic. |
| 53 | func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | 66 | func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | ||
| 54 | - | validator := NewURLValidator() | 67 | + | // Use the working CLI parsing logic instead |
| 55 | - | normalizedURL := validator.NormalizeURL(cleanInput) | 68 | + | finalURL, err := p.parseInputUsingCLILogic(cleanInput) |
| 69 | + | if err != nil { | |||
| 70 | + | return nil, err | |||
| 71 | + | } | |||
| 56 | 72 | ||||
| 57 | return &ParseResult{ | 73 | return &ParseResult{ | ||
| 58 | Type: InputTypeDirectURL, | 74 | Type: InputTypeDirectURL, | ||
| 59 | - | URL: normalizedURL, | 75 | + | URL: finalURL, |
| 60 | Query: originalInput, | 76 | Query: originalInput, | ||
| 61 | - | Confidence: 1.0, // High confidence for direct URLs | 77 | + | Confidence: 1.0, |
| 62 | ProcessingTime: time.Since(startTime), | 78 | ProcessingTime: time.Since(startTime), | ||
| 63 | }, nil | 79 | }, nil | ||
| 64 | } | 80 | } | ||
| 65 | 81 | ||||
| 82 | + | // parseInputUsingCLILogic implements the working CLI parsing logic | |||
| 83 | + | func (p *Parser) parseInputUsingCLILogic(input string) (string, error) { | |||
| 84 | + | input = strings.TrimSpace(input) | |||
| 85 | + | ||||
| 86 | + | // 1. Check if it's a shortcut (format: "prefix:query") | |||
| 87 | + | if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") { | |||
| 88 | + | shortcut := strings.TrimSpace(input[:idx]) | |||
| 89 | + | query := strings.TrimSpace(input[idx+1:]) | |||
| 90 | + | ||||
| 91 | + | if query != "" { | |||
| 92 | + | // First check configuration-based shortcuts | |||
| 93 | + | if shortcutCfg, exists := p.config.SearchShortcuts[shortcut]; exists { | |||
| 94 | + | return fmt.Sprintf(shortcutCfg.URL, neturl.QueryEscape(query)), nil | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | return "", fmt.Errorf("unknown shortcut '%s'", shortcut) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // 2. Already a full URL with protocol | |||
| 102 | + | if regexp.MustCompile(`^https?://`).MatchString(input) { | |||
| 103 | + | return input, nil | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // 3. File protocol | |||
| 107 | + | if strings.HasPrefix(input, "file://") { | |||
| 108 | + | return input, nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // 4. Localhost/development URLs | |||
| 112 | + | if regexp.MustCompile(`^(localhost|127\.0\.0\.1|0\.0\.0\.0|::1)(:\d+)?(/.*)?$`).MatchString(input) { | |||
| 113 | + | return "http://" + input, nil | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // 5. Local network IPs | |||
| 117 | + | if regexp.MustCompile(`^(192\.168|10\.|172\.(1[6-9]|2[0-9]|3[01]))\.\d+\.\d+(:\d+)?(/.*)?$`).MatchString(input) { | |||
| 118 | + | return "http://" + input, nil | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // 6. Domain with TLD (must have dot and valid TLD) | |||
| 122 | + | // More comprehensive TLD pattern for better URL detection | |||
| 123 | + | domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(/.*)?$`) | |||
| 124 | + | if domainPattern.MatchString(input) { | |||
| 125 | + | // Additional check for common TLDs to reduce false positives | |||
| 126 | + | commonTLDs := regexp.MustCompile(`\.(com|org|net|edu|gov|io|co|uk|de|fr|jp|cn|in|br|au|ca|ru|nl|it|es|se|no|fi|dk|pl|ch|at|be|cz|gr|il|mx|nz|sg|kr|tw|hk|my|th|vn|id|ph|za|eg|ng|ke|dev|app|xyz|tech|site|online|store|blog|info|biz|name|pro)(/.*)?$`) | |||
| 127 | + | if commonTLDs.MatchString(input) { | |||
| 128 | + | return "https://" + input, nil | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // If it has a dot but uncommon TLD, still try as URL | |||
| 132 | + | return "https://" + input, nil | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // 7. Possible local hostname (single word, might be on local network) | |||
| 136 | + | singleHostPattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`) | |||
| 137 | + | if singleHostPattern.MatchString(input) && len(input) <= 63 { | |||
| 138 | + | // Could be local hostname, try as URL but don't validate | |||
| 139 | + | return "http://" + input, nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // 8. Everything else is a search query | |||
| 143 | + | // This includes: multi-word phrases, questions, special characters, etc. | |||
| 144 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input)), nil | |||
| 145 | + | } | |||
| 146 | + | ||||
| 66 | // parseSearchShortcut handles search shortcut inputs. | 147 | // parseSearchShortcut handles search shortcut inputs. | ||
| 67 | func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | 148 | func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | ||
| 68 | validator := NewURLValidator() | 149 | validator := NewURLValidator() | ||
A migrations/004_add_favicon_url.sql +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | -- Add favicon_url column to history table | |
| 2 | + | -- Store favicon URLs for better UI display in dmenu | |
| 3 | + | ||
| 4 | + | ALTER TABLE history ADD COLUMN favicon_url TEXT DEFAULT NULL; | |
| 5 | + | CREATE INDEX IF NOT EXISTS idx_history_favicon ON history(favicon_url);⏎̸ | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | -- Add favicon_url column to history table | |||
| 2 | + | -- Store favicon URLs for better UI display in dmenu | |||
| 3 | + | ||||
| 4 | + | ALTER TABLE history ADD COLUMN favicon_url TEXT DEFAULT NULL; | |||
| 5 | + | CREATE INDEX IF NOT EXISTS idx_history_favicon ON history(favicon_url);⏎̸ | |||
M services/browser_service.go +35 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | neturl "net/url" | |
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/dumber/internal/cache" | |
| 14 | 15 | "github.com/bnema/dumber/internal/config" | |
| 15 | 16 | "github.com/bnema/dumber/internal/db" | |
| 16 | 17 | "github.com/bnema/dumber/pkg/webkit" | |
| @@ -140,6 +141,9 @@ func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationR | |||
| 140 | 141 | fmt.Printf("Failed to record history: %v\n", err) | |
| 141 | 142 | } | |
| 142 | 143 | ||
| 144 | + | // Update favicon asynchronously after navigation | |
| 145 | + | go s.updateFavicon(ctx, url) | |
| 146 | + | ||
| 143 | 147 | return &NavigationResult{ | |
| 144 | 148 | URL: url, | |
| 145 | 149 | Success: true, | |
| @@ -486,3 +490,34 @@ func zoomKeyFromURL(raw string) string { | |||
| 486 | 490 | ||
| 487 | 491 | // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages. | |
| 488 | 492 | func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) } | |
| 493 | + | ||
| 494 | + | // updateFavicon fetches and stores favicon URL for a given page URL | |
| 495 | + | func (s *BrowserService) updateFavicon(ctx context.Context, pageURL string) { | |
| 496 | + | parsedURL, err := neturl.Parse(pageURL) | |
| 497 | + | if err != nil { | |
| 498 | + | return // Silently fail for invalid URLs | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | // Skip favicon update for localhost, file://, or special schemes | |
| 502 | + | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { | |
| 503 | + | return | |
| 504 | + | } | |
| 505 | + | if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" { | |
| 506 | + | return | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | // Standard favicon location | |
| 510 | + | faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host) | |
| 511 | + | ||
| 512 | + | // Update in database | |
| 513 | + | faviconNullString := sql.NullString{String: faviconURL, Valid: true} | |
| 514 | + | if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | |
| 515 | + | // Silently fail - favicon is not critical | |
| 516 | + | return | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // Also cache the favicon for dmenu use | |
| 520 | + | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |
| 521 | + | faviconCache.CacheAsync(faviconURL) | |
| 522 | + | } | |
| 523 | + | } | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | neturl "net/url" | 11 | neturl "net/url" | ||
| 12 | "time" | 12 | "time" | ||
| 13 | 13 | ||||
| 14 | + | "github.com/bnema/dumber/internal/cache" | |||
| 14 | "github.com/bnema/dumber/internal/config" | 15 | "github.com/bnema/dumber/internal/config" | ||
| 15 | "github.com/bnema/dumber/internal/db" | 16 | "github.com/bnema/dumber/internal/db" | ||
| 16 | "github.com/bnema/dumber/pkg/webkit" | 17 | "github.com/bnema/dumber/pkg/webkit" | ||
| @@ -140,6 +141,9 @@ func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationR | |||||
| 140 | fmt.Printf("Failed to record history: %v\n", err) | 141 | fmt.Printf("Failed to record history: %v\n", err) | ||
| 141 | } | 142 | } | ||
| 142 | 143 | ||||
| 144 | + | // Update favicon asynchronously after navigation | |||
| 145 | + | go s.updateFavicon(ctx, url) | |||
| 146 | + | ||||
| 143 | return &NavigationResult{ | 147 | return &NavigationResult{ | ||
| 144 | URL: url, | 148 | URL: url, | ||
| 145 | Success: true, | 149 | Success: true, | ||
| @@ -486,3 +490,34 @@ func zoomKeyFromURL(raw string) string { | |||||
| 486 | 490 | ||||
| 487 | // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages. | 491 | // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages. | ||
| 488 | func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) } | 492 | func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) } | ||
| 493 | + | ||||
| 494 | + | // updateFavicon fetches and stores favicon URL for a given page URL | |||
| 495 | + | func (s *BrowserService) updateFavicon(ctx context.Context, pageURL string) { | |||
| 496 | + | parsedURL, err := neturl.Parse(pageURL) | |||
| 497 | + | if err != nil { | |||
| 498 | + | return // Silently fail for invalid URLs | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | // Skip favicon update for localhost, file://, or special schemes | |||
| 502 | + | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { | |||
| 503 | + | return | |||
| 504 | + | } | |||
| 505 | + | if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" { | |||
| 506 | + | return | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | // Standard favicon location | |||
| 510 | + | faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host) | |||
| 511 | + | ||||
| 512 | + | // Update in database | |||
| 513 | + | faviconNullString := sql.NullString{String: faviconURL, Valid: true} | |||
| 514 | + | if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | |||
| 515 | + | // Silently fail - favicon is not critical | |||
| 516 | + | return | |||
| 517 | + | } | |||
| 518 | + | ||||
| 519 | + | // Also cache the favicon for dmenu use | |||
| 520 | + | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |||
| 521 | + | faviconCache.CacheAsync(faviconURL) | |||
| 522 | + | } | |||
| 523 | + | } | |||