diff --git a/cache-images.go b/cache-images.go index 4e551cd..692476e 100644 --- a/cache-images.go +++ b/cache-images.go @@ -19,6 +19,7 @@ import ( "time" "github.com/chai2010/webp" + "github.com/fyne-io/image/ico" "golang.org/x/image/bmp" "golang.org/x/image/tiff" ) @@ -139,6 +140,8 @@ func cacheImage(imageURL, imageID string, isThumbnail bool) (string, bool, error // Decode the image based on the content type var img image.Image switch contentType { + case "image/x-icon", "image/vnd.microsoft.icon": + img, err = ico.Decode(bytes.NewReader(data)) case "image/jpeg": img, err = jpeg.Decode(bytes.NewReader(data)) case "image/png": diff --git a/common.go b/common.go index 48e222b..257ba6d 100755 --- a/common.go +++ b/common.go @@ -8,6 +8,7 @@ import ( "html/template" mathrand "math/rand" "net/http" + "net/url" "strings" "time" ) @@ -36,6 +37,11 @@ type SearchEngine struct { Func func(string, string, string, int) ([]SearchResult, time.Duration, error) } +type LinkParts struct { + Domain template.HTML + Path template.HTML +} + // Helper function to render templates without elapsed time measurement func renderTemplate(w http.ResponseWriter, tmplName string, data map[string]interface{}) { // Generate icon paths for SVG and PNG, including a 1/10 chance for an alternate icon @@ -125,3 +131,48 @@ func FormatElapsedTime(elapsed time.Duration) string { } return fmt.Sprintf("%.2f %s", elapsed.Seconds(), Translate("seconds")) } + +func FormatURLParts(rawURL string) (domain, path string) { + parsed, err := url.Parse(rawURL) + if err != nil { + return rawURL, "" + } + + domain = parsed.Host + if strings.HasPrefix(domain, "www.") { + domain = domain[4:] + } + + // Clean up the path - remove empty segments and trailing slashes + path = strings.Trim(parsed.Path, "/") + pathSegments := strings.Split(path, "/") + + // Filter out empty segments + var cleanSegments []string + for _, seg := range pathSegments { + if seg != "" { + cleanSegments = append(cleanSegments, seg) + } + } + + path = strings.Join(cleanSegments, "/") + return domain, path +} + +func FormatLinkHTML(rawURL string) LinkParts { + domain, path := FormatURLParts(rawURL) + + if path == "" { + return LinkParts{ + Domain: template.HTML(fmt.Sprintf(`%s`, template.HTMLEscapeString(domain))), + } + } + + // Only add separators between non-empty path segments + pathDisplay := strings.ReplaceAll(path, "/", " › ") + + return LinkParts{ + Domain: template.HTML(fmt.Sprintf(`%s`, template.HTMLEscapeString(domain))), + Path: template.HTML(fmt.Sprintf(` › %s`, template.HTMLEscapeString(pathDisplay))), + } +} diff --git a/favicon.go b/favicon.go new file mode 100644 index 0000000..837e3f9 --- /dev/null +++ b/favicon.go @@ -0,0 +1,549 @@ +package main + +import ( + "bytes" + "crypto/md5" + "crypto/tls" + "encoding/base64" + "encoding/hex" + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/chai2010/webp" + "github.com/fyne-io/image/ico" + "golang.org/x/image/bmp" + "golang.org/x/image/draw" + "golang.org/x/image/tiff" + "golang.org/x/net/html" +) + +var ( + faviconCache = struct { + sync.RWMutex + m map[string]bool // tracks in-progress downloads + }{m: make(map[string]bool)} + + // Common favicon paths to try + commonFaviconPaths = []string{ + "/favicon.ico", + "/favicon.png", + "/favicon.jpg", + "/favicon.jpeg", + "/favicon.webp", + "/apple-touch-icon.png", + "/apple-touch-icon-precomposed.png", + } + + // Regex to extract favicon URLs from HTML + iconLinkRegex = regexp.MustCompile(`]+rel=["'](?:icon|shortcut icon|apple-touch-icon)["'][^>]+href=["']([^"']+)["']`) +) + +// Generates a cache ID from URL +func faviconIDFromURL(rawURL string) string { + hasher := md5.New() + hasher.Write([]byte(rawURL)) + return hex.EncodeToString(hasher.Sum(nil)) +} + +// Resolves favicon URL using multiple methods +func resolveFaviconURL(rawFavicon, pageURL string) (faviconURL, cacheID string) { + // Handle data URLs first + if strings.HasPrefix(rawFavicon, "data:image") { + parts := strings.SplitN(rawFavicon, ";base64,", 2) + if len(parts) == 2 { + data, err := base64.StdEncoding.DecodeString(parts[1]) + if err == nil { + hasher := md5.New() + hasher.Write(data) + return rawFavicon, hex.EncodeToString(hasher.Sum(nil)) + } + } + return "", "" // Invalid data URL + } + + // Existing URL handling logic + if rawFavicon != "" && strings.HasPrefix(rawFavicon, "http") { + cacheID = faviconIDFromURL(rawFavicon) + return rawFavicon, cacheID + } + + parsedPage, err := url.Parse(pageURL) + if err != nil { + return "", "" + } + + // Method 1: Parse HTML + if favicon := findFaviconInHTML(pageURL); favicon != "" { + if strings.HasPrefix(favicon, "http") { + return favicon, faviconIDFromURL(favicon) + } + resolved := resolveRelativeURL(parsedPage, favicon) + return resolved, faviconIDFromURL(resolved) + } + + // Method 2: Common paths + for _, path := range commonFaviconPaths { + testURL := "https://" + parsedPage.Host + path + if checkURLExists(testURL) { + return testURL, faviconIDFromURL(testURL) + } + } + + // Method 3: HTTP headers + if headerIcon := findFaviconInHeaders(pageURL); headerIcon != "" { + if strings.HasPrefix(headerIcon, "http") { + return headerIcon, faviconIDFromURL(headerIcon) + } + resolved := resolveRelativeURL(parsedPage, headerIcon) + return resolved, faviconIDFromURL(resolved) + } + + // Fallback + fallbackURL := "https://" + parsedPage.Host + "/favicon.ico" + return fallbackURL, faviconIDFromURL(fallbackURL) +} + +// Checks HTTP headers for favicon links +func findFaviconInHeaders(pageURL string) string { + client := &http.Client{ + Timeout: 3 * time.Second, // like 3 seconds for favicon should be enough + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + req, err := http.NewRequest("HEAD", pageURL, nil) + if err != nil { + return "" + } + + // Add User-Agent + userAgent, err := GetUserAgent("findFaviconInHeaders") + if err != nil { + printWarn("Error getting User-Agent: %v", err) + } + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + // Check Link headers (common for favicons) + if links, ok := resp.Header["Link"]; ok { + for _, link := range links { + parts := strings.Split(link, ";") + if len(parts) < 2 { + continue + } + + urlPart := strings.TrimSpace(parts[0]) + if !strings.HasPrefix(urlPart, "<") || !strings.HasSuffix(urlPart, ">") { + continue + } + + urlPart = urlPart[1 : len(urlPart)-1] // Remove < and > + for _, part := range parts[1:] { + part = strings.TrimSpace(part) + if strings.EqualFold(part, `rel="icon"`) || + strings.EqualFold(part, `rel=icon`) || + strings.EqualFold(part, `rel="shortcut icon"`) || + strings.EqualFold(part, `rel=shortcut icon`) { + return urlPart + } + } + } + } + + return "" +} + +// Helper to resolve relative URLs +func resolveRelativeURL(base *url.URL, relative string) string { + if strings.HasPrefix(relative, "http") { + return relative + } + if strings.HasPrefix(relative, "//") { + return base.Scheme + ":" + relative + } + if strings.HasPrefix(relative, "/") { + return base.Scheme + "://" + base.Host + relative + } + return base.Scheme + "://" + base.Host + base.Path + "/" + relative +} + +// Checks if a URL exists (returns 200 OK) +func checkURLExists(url string) bool { + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + req, err := http.NewRequest("HEAD", url, nil) + if err != nil { + return false + } + + // Add User-Agent + userAgent, err := GetUserAgent("Text-Search-Brave") + if err != nil { + printWarn("Error getting User-Agent: %v", err) + } + req.Header.Set("checkURLExists", userAgent) + + resp, err := client.Do(req) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// Fetches HTML and looks for favicon links +func findFaviconInHTML(pageURL string) string { + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + req, err := http.NewRequest("GET", pageURL, nil) + if err != nil { + return "" + } + + // Add User-Agent + userAgent, err := GetUserAgent("findFaviconInHTML") + if err != nil { + printWarn("Error getting User-Agent: %v", err) + } + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + // Check if this is an AMP page + isAMP := false + for _, attr := range resp.Header["Link"] { + if strings.Contains(attr, "rel=\"amphtml\"") { + isAMP = true + break + } + } + + // Parse HTML + doc, err := html.Parse(resp.Body) + if err != nil { + return "" + } + + var faviconURL string + var findLinks func(*html.Node) + findLinks = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "link" { + var rel, href string + for _, attr := range n.Attr { + switch attr.Key { + case "rel": + rel = attr.Val + case "href": + href = attr.Val + } + } + + // Prioritize different favicon types + if href != "" { + switch rel { + case "icon", "shortcut icon", "apple-touch-icon", "apple-touch-icon-precomposed": + // For AMP pages, prefer the non-versioned URL if possible + if isAMP { + if u, err := url.Parse(href); err == nil { + u.RawQuery = "" // Remove query parameters + href = u.String() + } + } + if faviconURL == "" || // First found + rel == "apple-touch-icon" || // Prefer apple-touch-icon + rel == "icon" { // Then regular icon + faviconURL = href + } + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + findLinks(c) + } + } + findLinks(doc) + + return faviconURL +} + +// Get proxy URL (cached) - remains mostly the same +func getFaviconProxyURL(rawFavicon, pageURL string) string { + // First try cache without any locks + cacheID := faviconIDFromURL(pageURL) // Simple hash of pageURL + filename := fmt.Sprintf("%s_thumb.webp", cacheID) + cachedPath := filepath.Join(config.DriveCache.Path, "images", filename) + + if _, err := os.Stat(cachedPath); err == nil { + return fmt.Sprintf("/image/%s_thumb.webp", cacheID) + } + + // Cache miss - resolve favicon URL (may hit network) + faviconURL, cacheID := resolveFaviconURL(rawFavicon, pageURL) + if faviconURL == "" || cacheID == "" { + return "/static/images/missing.svg" + } + + // Recheck cache after resolution (in case another request cached it) + if _, err := os.Stat(cachedPath); err == nil { + return fmt.Sprintf("/image/%s_thumb.webp", cacheID) + } + + // Check download status with lock + faviconCache.RLock() + downloading := faviconCache.m[cacheID] + faviconCache.RUnlock() + + if !downloading { + faviconCache.Lock() + faviconCache.m[cacheID] = true + faviconCache.Unlock() + + go func() { + defer func() { + faviconCache.Lock() + delete(faviconCache.m, cacheID) + faviconCache.Unlock() + }() + cacheFavicon(faviconURL, cacheID) + }() + } + + return fmt.Sprintf("/image/%s_thumb.webp", cacheID) +} + +// Caches favicon, always saving *_thumb.webp +func cacheFavicon(imageURL, imageID string) (string, bool, error) { + if imageURL == "" { + recordInvalidImageID(imageID) + return "", false, fmt.Errorf("empty image URL for image ID %s", imageID) + } + + filename := fmt.Sprintf("%s_thumb.webp", imageID) + imageCacheDir := filepath.Join(config.DriveCache.Path, "images") + if err := os.MkdirAll(imageCacheDir, 0755); err != nil { + return "", false, fmt.Errorf("couldn't create images folder: %v", err) + } + cachedImagePath := filepath.Join(imageCacheDir, filename) + tempImagePath := cachedImagePath + ".tmp" + + // Already cached? + if _, err := os.Stat(cachedImagePath); err == nil { + return cachedImagePath, true, nil + } + + cachingImagesMu.Lock() + if _, exists := cachingImages[imageURL]; !exists { + cachingImages[imageURL] = &sync.Mutex{} + } + mu := cachingImages[imageURL] + cachingImagesMu.Unlock() + + mu.Lock() + defer mu.Unlock() + + // Recheck after lock + if _, err := os.Stat(cachedImagePath); err == nil { + return cachedImagePath, true, nil + } + + cachingSemaphore <- struct{}{} + defer func() { <-cachingSemaphore }() + + var data []byte + var contentType string + + // Handle data URLs + if strings.HasPrefix(imageURL, "data:") { + commaIndex := strings.Index(imageURL, ",") + if commaIndex == -1 { + recordInvalidImageID(imageID) + return "", false, fmt.Errorf("invalid data URL: no comma") + } + headerPart := imageURL[:commaIndex] + dataPart := imageURL[commaIndex+1:] + + mediaType := "text/plain" + base64Encoded := false + if strings.HasPrefix(headerPart, "data:") { + mediaTypePart := headerPart[5:] + mediaTypeParts := strings.SplitN(mediaTypePart, ";", 2) + mediaType = mediaTypeParts[0] + if len(mediaTypeParts) > 1 { + for _, param := range strings.Split(mediaTypeParts[1], ";") { + param = strings.TrimSpace(param) + if param == "base64" { + base64Encoded = true + } + } + } + } + + if base64Encoded { + data, _ = base64.StdEncoding.DecodeString(dataPart) + } else { + decodedStr, err := url.QueryUnescape(dataPart) + if err != nil { + data = []byte(dataPart) + } else { + data = []byte(decodedStr) + } + } + + contentType = mediaType + } else { + // Download from HTTP URL + client := &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + req, err := http.NewRequest("GET", imageURL, nil) + if err != nil { + recordInvalidImageID(imageID) + return "", false, err + } + + // Add User-Agent + userAgent, err := GetUserAgent("Text-Search-Brave") + if err != nil { + printWarn("Error getting User-Agent: %v", err) + } + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + recordInvalidImageID(imageID) + return "", false, err + } + defer resp.Body.Close() + + data, err = io.ReadAll(resp.Body) + if err != nil { + recordInvalidImageID(imageID) + return "", false, err + } + + contentType = http.DetectContentType(data) + } + + if !strings.HasPrefix(contentType, "image/") { + recordInvalidImageID(imageID) + return "", false, fmt.Errorf("URL did not return an image: %s", imageURL) + } + + // SVG special case + if contentType == "image/svg+xml" { + err := os.WriteFile(tempImagePath, data, 0644) + if err != nil { + recordInvalidImageID(imageID) + return "", false, err + } + err = os.Rename(tempImagePath, cachedImagePath) + if err != nil { + recordInvalidImageID(imageID) + return "", false, err + } + cachingImagesMu.Lock() + delete(cachingImages, imageURL) + cachingImagesMu.Unlock() + return cachedImagePath, true, nil + } + + // Decode image + var img image.Image + var err error + switch contentType { + case "image/x-icon", "image/vnd.microsoft.icon": + img, err = ico.Decode(bytes.NewReader(data)) + case "image/jpeg": + img, err = jpeg.Decode(bytes.NewReader(data)) + case "image/png": + img, err = png.Decode(bytes.NewReader(data)) + case "image/gif": + img, err = gif.Decode(bytes.NewReader(data)) + case "image/webp": + img, err = webp.Decode(bytes.NewReader(data)) + case "image/bmp": + img, err = bmp.Decode(bytes.NewReader(data)) + case "image/tiff": + img, err = tiff.Decode(bytes.NewReader(data)) + default: + recordInvalidImageID(imageID) + return "", false, fmt.Errorf("unsupported image type: %s", contentType) + } + if err != nil { + recordInvalidImageID(imageID) + return "", false, err + } + + // Resize + maxSize := 16 + width := img.Bounds().Dx() + height := img.Bounds().Dy() + + if width > maxSize || height > maxSize { + dst := image.NewRGBA(image.Rect(0, 0, maxSize, maxSize)) + draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil) + img = dst + } + + // Save as WebP + outFile, err := os.Create(tempImagePath) + if err != nil { + recordInvalidImageID(imageID) + return "", false, err + } + defer outFile.Close() + + options := &webp.Options{Lossless: false, Quality: 80} + err = webp.Encode(outFile, img, options) + if err != nil { + recordInvalidImageID(imageID) + return "", false, err + } + + err = os.Rename(tempImagePath, cachedImagePath) + if err != nil { + recordInvalidImageID(imageID) + return "", false, err + } + + cachingImagesMu.Lock() + delete(cachingImages, imageURL) + cachingImagesMu.Unlock() + + return cachedImagePath, true, nil +} diff --git a/go.mod b/go.mod index f7d89ad..b088e24 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/blevesearch/bleve/v2 v2.4.4 github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb github.com/chromedp/chromedp v0.11.2 + github.com/fyne-io/image v0.1.1 github.com/go-shiori/go-readability v0.0.0-20241012063810-92284fa8a71f golang.org/x/net v0.33.0 ) @@ -55,11 +56,11 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mschoch/smat v0.2.0 // indirect - github.com/stretchr/testify v1.9.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.3.11 // indirect golang.org/x/sys v0.28.0 // indirect diff --git a/go.sum b/go.sum index 66cede6..752f0ed 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHG github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= @@ -84,6 +86,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leonelquinteros/gotext v1.7.0 h1:jcJmF4AXqyamP7vuw2MMIKs+O3jAEmvrc5JQiI8Ht/8= @@ -111,8 +115,8 @@ github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMT github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= diff --git a/static/css/style.css b/static/css/style.css index ce09e7d..0f4781a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1309,6 +1309,96 @@ p { text-shadow: 1px 1px 2px var(--border) !important; /* Adjust text shadow */ } +/* Favicon styling */ +.result_header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.favicon-container { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: 1px solid var(--fg); + border-radius: 50%; + padding: 2px; + flex-shrink: 0; +} + +.favicon { + width: 16px; + height: 16px; + object-fit: contain; + margin-bottom: 2px; + border-radius: 3px; + margin-left: -0.5px; /* this is just lovely, I cannot express ho happy this makes me */ + margin-top: 1.5px; +} + +/* Result link styling */ +.result-link { + color: var(--fg); + font-size: 14px; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-link:hover { + text-decoration: underline; +} + +/* Result item spacing */ +.result_item { + margin-bottom: 1.5rem; +} + +.result-title h3 { + margin: 4px 0; + font-weight: 400; +} + +.result-description { + margin: 4px 0 0 0; + color: var(--font-fg); + line-height: 1.4; +} + +.results br { + display: none; +} + +.result-url { + font-size: 14px; + color: var(--fg); + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.result-domain { + color: var(--fg); + font-weight: 600; +} + +.result-path { + color: var(--font-fg); + font-weight: 500; + opacity: 0.8; +} +/* +.result-path::before { + content: "›"; + margin: 0 4px; + opacity: 0.6; +} */ + body, h1, p, a, input, button { color: var(--text-color); /* Applies the text color based on theme */ background-color: var(--background-color); /* Applies the background color based on theme */ diff --git a/templates/text.html b/templates/text.html index a55f77c..9d23620 100755 --- a/templates/text.html +++ b/templates/text.html @@ -141,24 +141,35 @@

{{ translate "fetched_in" .Fetched }}

- {{if .Results}} - {{range .Results}} -
- {{.URL}} -

{{.Header}}

-

{{.Description}}

+ {{ if .Results }} + {{ range .Results }} +
+
+
+ 🌐 +
+
+ {{ .PrettyLink.Domain }} + {{ if .PrettyLink.Path }} + {{ .PrettyLink.Path }} + {{ end }} +
-
- {{end}} - {{else if .NoResults}} +

{{ .Header }}

+

{{ .Description }}

+
+ {{ end }} + {{ else if .NoResults }}
{{ translate "no_results_found" .Query }}
{{ translate "suggest_rephrase" }}
- {{else}} -
{{ translate "no_more_results" }}
- {{end}} -
+ {{ else }} +
+ {{ translate "no_more_results" }} +
+ {{ end }} +
{{ translate "searching_for_new_results" }}...
diff --git a/text.go b/text.go index 0c418ae..7fdc803 100755 --- a/text.go +++ b/text.go @@ -33,7 +33,13 @@ func initTextEngines() { func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string, page int) { startTime := time.Now() - cacheKey := CacheKey{Query: query, Page: page, Safe: settings.SafeSearch == "active", Lang: settings.SearchLanguage, Type: "text"} + cacheKey := CacheKey{ + Query: query, + Page: page, + Safe: settings.SafeSearch == "active", + Lang: settings.SearchLanguage, + Type: "text", + } combinedResults := getTextResultsFromCacheOrFetch(cacheKey, query, settings.SafeSearch, settings.SearchLanguage, page) hasPrevPage := page > 1 @@ -46,13 +52,32 @@ func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string elapsedTime := time.Since(startTime) - // Prepare the data to pass to the template + // Prepare safe decorated results + type DecoratedResult struct { + TextSearchResult + FaviconURL string + PrettyLink LinkParts + } + + var decoratedResults []DecoratedResult + for _, r := range combinedResults { + if r.URL == "" { + continue + } + + decoratedResults = append(decoratedResults, DecoratedResult{ + TextSearchResult: r, + FaviconURL: getFaviconProxyURL("", r.URL), + PrettyLink: FormatLinkHTML(r.URL), + }) + } + data := map[string]interface{}{ - "Results": combinedResults, + "Results": decoratedResults, "Query": query, "Fetched": FormatElapsedTime(elapsedTime), "Page": page, - "HasPrevPage": page > 1, + "HasPrevPage": hasPrevPage, "HasNextPage": len(combinedResults) >= 50, "NoResults": len(combinedResults) == 0, "LanguageOptions": languageOptions, @@ -63,7 +88,7 @@ func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string "Trans": Translate, } - // Render the template without measuring time + // Render the template renderTemplate(w, "text.html", data) }