From bc89f5b819c6be2859a5271a92e87ec8edb5024a Mon Sep 17 00:00:00 2001 From: partisan Date: Mon, 28 Apr 2025 20:03:33 +0200 Subject: [PATCH 1/6] Initial favicon add --- cache-images.go | 3 + common.go | 51 ++++ favicon.go | 549 +++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 +- go.sum | 8 +- static/css/style.css | 90 +++++++ templates/text.html | 37 ++- text.go | 35 ++- 8 files changed, 755 insertions(+), 21 deletions(-) create mode 100644 favicon.go 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) } From 72cbfbab109f50fae41e91333a45ff05e5de14de Mon Sep 17 00:00:00 2001 From: partisan Date: Mon, 28 Apr 2025 22:27:12 +0200 Subject: [PATCH 2/6] this looks better --- static/css/style.css | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index 0f4781a..da6e8e6 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1321,22 +1321,16 @@ p { display: flex; align-items: center; justify-content: center; - width: 20px; - height: 20px; - border: 1px solid var(--fg); - border-radius: 50%; - padding: 2px; + height: 18px; + border-radius: 8%; 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; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.4); } /* Result link styling */ @@ -1385,6 +1379,7 @@ p { .result-domain { color: var(--fg); font-weight: 600; + opacity: 0.85; } .result-path { From 4a0738745ad2ab543f9b5eec60f2b049472bf6bf Mon Sep 17 00:00:00 2001 From: partisan Date: Wed, 7 May 2025 09:40:22 +0200 Subject: [PATCH 3/6] fixed favicon backend not really workin --- favicon.go | 40 +++++++++++++++++------------ static/js/dynamicscrollingimages.js | 3 +++ templates/text.html | 9 ++++++- text.go | 11 ++++++-- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/favicon.go b/favicon.go index 837e3f9..e338fc9 100644 --- a/favicon.go +++ b/favicon.go @@ -59,6 +59,8 @@ func faviconIDFromURL(rawURL string) string { // Resolves favicon URL using multiple methods func resolveFaviconURL(rawFavicon, pageURL string) (faviconURL, cacheID string) { + cacheID = faviconIDFromURL(pageURL) + // Handle data URLs first if strings.HasPrefix(rawFavicon, "data:image") { parts := strings.SplitN(rawFavicon, ";base64,", 2) @@ -297,10 +299,12 @@ func findFaviconInHTML(pageURL string) string { 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 + if pageURL == "" { + return "/static/images/missing.svg" + } + + cacheID := faviconIDFromURL(pageURL) filename := fmt.Sprintf("%s_thumb.webp", cacheID) cachedPath := filepath.Join(config.DriveCache.Path, "images", filename) @@ -308,18 +312,14 @@ func getFaviconProxyURL(rawFavicon, pageURL string) string { return fmt.Sprintf("/image/%s_thumb.webp", cacheID) } - // Cache miss - resolve favicon URL (may hit network) - faviconURL, cacheID := resolveFaviconURL(rawFavicon, pageURL) - if faviconURL == "" || cacheID == "" { + // Resolve URL (but ignore resolved ID — we always use the one from pageURL) + faviconURL, _ := resolveFaviconURL(rawFavicon, pageURL) + if faviconURL == "" { + recordInvalidImageID(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 + // Avoid re-downloading faviconCache.RLock() downloading := faviconCache.m[cacheID] faviconCache.RUnlock() @@ -335,7 +335,10 @@ func getFaviconProxyURL(rawFavicon, pageURL string) string { delete(faviconCache.m, cacheID) faviconCache.Unlock() }() - cacheFavicon(faviconURL, cacheID) + _, _, err := cacheFavicon(faviconURL, cacheID) + if err != nil { + recordInvalidImageID(cacheID) + } }() } @@ -344,10 +347,13 @@ func getFaviconProxyURL(rawFavicon, pageURL string) string { // 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) - } + // if imageURL == "" { + // recordInvalidImageID(imageID) + // return "", false, fmt.Errorf("empty image URL for image ID %s", imageID) + // } + + // Debug + fmt.Printf("Downloading favicon [%s] for ID [%s]\n", imageURL, imageID) filename := fmt.Sprintf("%s_thumb.webp", imageID) imageCacheDir := filepath.Join(config.DriveCache.Path, "images") diff --git a/static/js/dynamicscrollingimages.js b/static/js/dynamicscrollingimages.js index 731f8e4..a15b253 100644 --- a/static/js/dynamicscrollingimages.js +++ b/static/js/dynamicscrollingimages.js @@ -2,6 +2,9 @@ (function() { // Add loading effects to image and title function addLoadingEffects(imgElement) { + const container = imgElement.closest('.image'); + if (!container) return; // avoid null dereference + const title = imgElement.closest('.image').querySelector('.img_title'); imgElement.classList.add('loading-image'); title.classList.add('title-loading'); diff --git a/templates/text.html b/templates/text.html index 9d23620..b04f4f1 100755 --- a/templates/text.html +++ b/templates/text.html @@ -146,7 +146,13 @@
- 🌐 + 🌐
{{ .PrettyLink.Domain }} @@ -191,6 +197,7 @@ + diff --git a/text.go b/text.go index 7fdc803..5fddc9f 100755 --- a/text.go +++ b/text.go @@ -56,6 +56,7 @@ func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string type DecoratedResult struct { TextSearchResult FaviconURL string + FaviconID string PrettyLink LinkParts } @@ -65,10 +66,16 @@ func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string continue } + // First format the link + prettyLink := FormatLinkHTML(r.URL) + faviconID := faviconIDFromURL(r.URL) + faviconURL := getFaviconProxyURL("", r.URL) //string(prettyLink.Domain) + decoratedResults = append(decoratedResults, DecoratedResult{ TextSearchResult: r, - FaviconURL: getFaviconProxyURL("", r.URL), - PrettyLink: FormatLinkHTML(r.URL), + PrettyLink: prettyLink, + FaviconID: faviconID, + FaviconURL: faviconURL, }) } From 255acb360f51037e2801c7a6fc05675a1f655372 Mon Sep 17 00:00:00 2001 From: partisan Date: Wed, 7 May 2025 12:54:51 +0200 Subject: [PATCH 4/6] iproved favicon caching / ID system --- common.go | 39 ++++++++++++++++++--------------------- text.go | 4 ++-- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/common.go b/common.go index 257ba6d..326c01c 100755 --- a/common.go +++ b/common.go @@ -38,8 +38,9 @@ type SearchEngine struct { } type LinkParts struct { - Domain template.HTML - Path template.HTML + Domain template.HTML + Path template.HTML + RootURL string // used by getFaviconProxyURL() } // Helper function to render templates without elapsed time measurement @@ -131,11 +132,10 @@ func FormatElapsedTime(elapsed time.Duration) string { } return fmt.Sprintf("%.2f %s", elapsed.Seconds(), Translate("seconds")) } - -func FormatURLParts(rawURL string) (domain, path string) { +func FormatURLParts(rawURL string) (domain, path, rootURL string) { parsed, err := url.Parse(rawURL) - if err != nil { - return rawURL, "" + if err != nil || parsed.Host == "" { + return "", "", "" } domain = parsed.Host @@ -143,36 +143,33 @@ func FormatURLParts(rawURL string) (domain, path string) { domain = domain[4:] } - // Clean up the path - remove empty segments and trailing slashes + rootURL = parsed.Scheme + "://" + parsed.Host + 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 + return domain, path, rootURL } func FormatLinkHTML(rawURL string) LinkParts { - domain, path := FormatURLParts(rawURL) + domain, path, root := FormatURLParts(rawURL) - if path == "" { - return LinkParts{ - Domain: template.HTML(fmt.Sprintf(`%s`, template.HTMLEscapeString(domain))), - } + lp := LinkParts{ + RootURL: root, } - // Only add separators between non-empty path segments - pathDisplay := strings.ReplaceAll(path, "/", " › ") + lp.Domain = template.HTML(fmt.Sprintf(`%s`, template.HTMLEscapeString(domain))) - return LinkParts{ - Domain: template.HTML(fmt.Sprintf(`%s`, template.HTMLEscapeString(domain))), - Path: template.HTML(fmt.Sprintf(` › %s`, template.HTMLEscapeString(pathDisplay))), + if path != "" { + pathDisplay := strings.ReplaceAll(path, "/", " › ") + lp.Path = template.HTML(fmt.Sprintf(` › %s`, template.HTMLEscapeString(pathDisplay))) } + + return lp } diff --git a/text.go b/text.go index 5fddc9f..7dedb63 100755 --- a/text.go +++ b/text.go @@ -68,8 +68,8 @@ func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string // First format the link prettyLink := FormatLinkHTML(r.URL) - faviconID := faviconIDFromURL(r.URL) - faviconURL := getFaviconProxyURL("", r.URL) //string(prettyLink.Domain) + faviconID := faviconIDFromURL(prettyLink.RootURL) + faviconURL := getFaviconProxyURL("", prettyLink.RootURL) decoratedResults = append(decoratedResults, DecoratedResult{ TextSearchResult: r, From 81fb811111683b735df15e7e31d0fa1200d66122 Mon Sep 17 00:00:00 2001 From: partisan Date: Fri, 9 May 2025 08:26:14 +0200 Subject: [PATCH 5/6] added dynamic loading of favicons --- favicon.go | 45 ++++-- static/js/dynamicscrollingtext.js | 248 ++++++++++++++++++++++++++++++ templates/text.html | 42 ++++- text.go | 55 ++++--- 4 files changed, 355 insertions(+), 35 deletions(-) create mode 100644 static/js/dynamicscrollingtext.js diff --git a/favicon.go b/favicon.go index e338fc9..6b9dbf2 100644 --- a/favicon.go +++ b/favicon.go @@ -50,6 +50,30 @@ var ( iconLinkRegex = regexp.MustCompile(`]+rel=["'](?:icon|shortcut icon|apple-touch-icon)["'][^>]+href=["']([^"']+)["']`) ) +// Add this near the top with other vars +var ( + faviconDownloadQueue = make(chan faviconDownloadRequest, 1000) +) + +type faviconDownloadRequest struct { + faviconURL string + pageURL string + cacheID string +} + +func init() { + // Start 5 worker goroutines to process favicon downloads + for i := 0; i < 5; i++ { + go faviconDownloadWorker() + } +} + +func faviconDownloadWorker() { + for req := range faviconDownloadQueue { + cacheFavicon(req.faviconURL, req.cacheID) + } +} + // Generates a cache ID from URL func faviconIDFromURL(rawURL string) string { hasher := md5.New() @@ -312,14 +336,14 @@ func getFaviconProxyURL(rawFavicon, pageURL string) string { return fmt.Sprintf("/image/%s_thumb.webp", cacheID) } - // Resolve URL (but ignore resolved ID — we always use the one from pageURL) + // Resolve URL faviconURL, _ := resolveFaviconURL(rawFavicon, pageURL) if faviconURL == "" { recordInvalidImageID(cacheID) return "/static/images/missing.svg" } - // Avoid re-downloading + // Check if already downloading faviconCache.RLock() downloading := faviconCache.m[cacheID] faviconCache.RUnlock() @@ -329,17 +353,12 @@ func getFaviconProxyURL(rawFavicon, pageURL string) string { faviconCache.m[cacheID] = true faviconCache.Unlock() - go func() { - defer func() { - faviconCache.Lock() - delete(faviconCache.m, cacheID) - faviconCache.Unlock() - }() - _, _, err := cacheFavicon(faviconURL, cacheID) - if err != nil { - recordInvalidImageID(cacheID) - } - }() + // Send to download queue instead of starting goroutine + faviconDownloadQueue <- faviconDownloadRequest{ + faviconURL: faviconURL, + pageURL: pageURL, + cacheID: cacheID, + } } return fmt.Sprintf("/image/%s_thumb.webp", cacheID) diff --git a/static/js/dynamicscrollingtext.js b/static/js/dynamicscrollingtext.js new file mode 100644 index 0000000..557fc34 --- /dev/null +++ b/static/js/dynamicscrollingtext.js @@ -0,0 +1,248 @@ +(function() { + // Get template data and configuration + const templateData = document.getElementById('template-data'); + const type = templateData.getAttribute('data-type'); + const hardCacheEnabled = templateData.getAttribute('data-hard-cache-enabled') === 'true'; + + // Track all favicon/image elements and their IDs + const allMediaElements = []; + const allMediaIds = []; + let statusCheckTimeout = null; + + // Add loading effects to image/favicon and associated text + function addLoadingEffects(imgElement) { + const container = imgElement.closest(type === 'image' ? '.image' : '.result_item'); + if (!container) return; + + const titleSelector = type === 'image' ? '.img_title' : '.result-url'; + const title = container.querySelector(titleSelector); + imgElement.closest('.favicon-wrapper')?.classList.add('loading'); + // if (title) title.classList.add('title-loading'); + } + + // Remove loading effects when image/favicon loads + function removeLoadingEffects(imgElement) { + const container = imgElement.closest(type === 'image' ? '.image' : '.result_item'); + const titleSelector = type === 'image' ? '.img_title' : '.result-url'; + const title = container?.querySelector(titleSelector); + imgElement.closest('.favicon-wrapper')?.classList.remove('loading'); + if (title) title.classList.remove('title-loading'); + + if (type === 'image' && imgElement.src.endsWith('/images/missing.svg')) { + container.remove(); + } + } + + // Handle image/favicon loading errors + function handleImageError(imgElement) { + const container = imgElement.closest(type === 'image' ? '.image' : '.result_item'); + const titleSelector = type === 'image' ? '.img_title' : '.result-url'; + const title = container?.querySelector(titleSelector); + + imgElement.closest('.favicon-wrapper')?.classList.remove('loading'); + if (title) title.classList.remove('title-loading'); + + if (type === 'image') { + container.style.display = 'none'; + } else { + imgElement.src = '/static/images/missing.svg'; + } + } + + // Shared configuration + const statusCheckInterval = 500; + const scrollThreshold = 500; + const loadingIndicator = document.getElementById('message-bottom-right'); + let loadingTimer; + let isFetching = false; + let page = parseInt(templateData.getAttribute('data-page')) || 1; + let query = templateData.getAttribute('data-query'); + let noMoreImages = false; + + function showLoadingMessage() { + loadingIndicator.classList.add('visible'); + } + + function hideLoadingMessage() { + loadingIndicator.classList.remove('visible'); + } + + function ensureScrollable() { + if (noMoreImages) return; + if (document.body.scrollHeight <= window.innerHeight) { + fetchNextPage(); + } + } + + // Register a new media element for tracking + function registerMediaElement(imgElement) { + const id = imgElement.getAttribute('data-id'); + if (!id || allMediaIds.includes(id)) return; + + // Wrap the image in a .favicon-wrapper if not already + if (!imgElement.parentElement.classList.contains('favicon-wrapper')) { + const wrapper = document.createElement('span'); + wrapper.classList.add('favicon-wrapper'); + imgElement.parentElement.replaceChild(wrapper, imgElement); + wrapper.appendChild(imgElement); + } + + // Track and style + allMediaElements.push(imgElement); + allMediaIds.push(id); + addLoadingEffects(imgElement); + + + if (hardCacheEnabled) { + imgElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + } else { + imgElement.src = '/static/images/placeholder.svg'; + } + + // Schedule a status check if not already pending + if (!statusCheckTimeout) { + statusCheckTimeout = setTimeout(checkMediaStatus, statusCheckInterval); + } + } + + // Check status of all tracked media elements + function checkMediaStatus() { + statusCheckTimeout = null; + + if (allMediaIds.length === 0) return; + + // Group IDs to avoid very long URLs + const idGroups = []; + for (let i = 0; i < allMediaIds.length; i += 50) { + idGroups.push(allMediaIds.slice(i, i + 50)); + } + + const checkGroup = (group) => { + return fetch(`/image_status?image_ids=${group.join(',')}`) + .then(response => response.json()) + .then(statusMap => { + const pendingElements = []; + const pendingIds = []; + + allMediaElements.forEach((imgElement, index) => { + const id = allMediaIds[index]; + if (group.includes(id)) { + if (statusMap[id]) { + if (imgElement.src !== statusMap[id]) { + imgElement.src = statusMap[id]; + imgElement.onload = () => removeLoadingEffects(imgElement); + imgElement.onerror = () => handleImageError(imgElement); + } + } else { + pendingElements.push(imgElement); + pendingIds.push(id); + } + } + }); + + // Update global arrays with remaining pending items + allMediaElements = pendingElements; + allMediaIds = pendingIds; + }); + }; + + // Process all groups sequentially + const processGroups = async () => { + for (const group of idGroups) { + try { + await checkGroup(group); + } catch (error) { + console.error('Status check error:', error); + } + } + + // If we still have pending items, schedule another check + if (allMediaIds.length > 0) { + statusCheckTimeout = setTimeout(checkMediaStatus, statusCheckInterval); + } + }; + + processGroups(); + } + + function fetchNextPage() { + if (isFetching || noMoreImages) return; + + loadingTimer = setTimeout(() => { + showLoadingMessage(); + }, 150); + + isFetching = true; + page += 1; + + fetch(`/search?q=${encodeURIComponent(query)}&t=${type}&p=${page}&ajax=true`) + .then(response => response.text()) + .then(html => { + clearTimeout(loadingTimer); + hideLoadingMessage(); + + let tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + let newItems = tempDiv.querySelectorAll(type === 'image' ? '.image' : '.result_item'); + + if (newItems.length > 0) { + let resultsContainer = document.querySelector(type === 'image' ? '.images' : '.results'); + newItems.forEach(item => { + let clonedItem = item.cloneNode(true); + resultsContainer.appendChild(clonedItem); + + // Register any new media elements + const img = clonedItem.querySelector('img[data-id]'); + if (img) { + registerMediaElement(img); + } + }); + + ensureScrollable(); + } else { + noMoreImages = true; + } + isFetching = false; + }) + .catch(error => { + clearTimeout(loadingTimer); + hideLoadingMessage(); + console.error('Fetch error:', error); + isFetching = false; + }); + } + + // Initialize all existing media elements + function initializeMediaElements() { + document.querySelectorAll('img[data-id]').forEach(img => { + registerMediaElement(img); + }); + + // Start periodic checks if hard cache is enabled + if (hardCacheEnabled && allMediaIds.length > 0) { + statusCheckTimeout = setTimeout(checkMediaStatus, statusCheckInterval); + } + } + + // Initialize when DOM is ready + if (document.readyState === 'complete') { + initializeMediaElements(); + } else { + window.addEventListener('load', initializeMediaElements); + } + + // Infinite scroll handler + window.addEventListener('scroll', () => { + if (isFetching || noMoreImages) return; + if (window.innerHeight + window.scrollY >= document.body.offsetHeight - scrollThreshold) { + fetchNextPage(); + } + }); + + // Clean up on page unload + window.addEventListener('beforeunload', () => { + if (statusCheckTimeout) { + clearTimeout(statusCheckTimeout); + } + }); +})(); \ No newline at end of file diff --git a/templates/text.html b/templates/text.html index b04f4f1..2f5f2fb 100755 --- a/templates/text.html +++ b/templates/text.html @@ -17,6 +17,45 @@ + @@ -194,10 +233,9 @@
- + - diff --git a/text.go b/text.go index 7dedb63..63ce269 100755 --- a/text.go +++ b/text.go @@ -1,7 +1,10 @@ package main import ( + "fmt" "net/http" + "os" + "path/filepath" "time" ) @@ -52,12 +55,11 @@ func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string elapsedTime := time.Since(startTime) - // Prepare safe decorated results + // Simplified result structure without waiting for favicons type DecoratedResult struct { TextSearchResult - FaviconURL string - FaviconID string PrettyLink LinkParts + FaviconID string // Just the ID, URL will be generated client-side } var decoratedResults []DecoratedResult @@ -66,39 +68,52 @@ func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string continue } - // First format the link prettyLink := FormatLinkHTML(r.URL) faviconID := faviconIDFromURL(prettyLink.RootURL) - faviconURL := getFaviconProxyURL("", prettyLink.RootURL) decoratedResults = append(decoratedResults, DecoratedResult{ TextSearchResult: r, PrettyLink: prettyLink, FaviconID: faviconID, - FaviconURL: faviconURL, }) + + // Start async favicon fetch if not already cached + go ensureFaviconIsCached(faviconID, prettyLink.RootURL) } data := map[string]interface{}{ - "Results": decoratedResults, - "Query": query, - "Fetched": FormatElapsedTime(elapsedTime), - "Page": page, - "HasPrevPage": hasPrevPage, - "HasNextPage": len(combinedResults) >= 50, - "NoResults": len(combinedResults) == 0, - "LanguageOptions": languageOptions, - "CurrentLang": settings.SearchLanguage, - "Theme": settings.Theme, - "Safe": settings.SafeSearch, - "IsThemeDark": settings.IsThemeDark, - "Trans": Translate, + "Results": decoratedResults, + "Query": query, + "Fetched": FormatElapsedTime(elapsedTime), + "Page": page, + "HasPrevPage": hasPrevPage, + "HasNextPage": len(combinedResults) >= 50, + "NoResults": len(combinedResults) == 0, + "LanguageOptions": languageOptions, + "CurrentLang": settings.SearchLanguage, + "Theme": settings.Theme, + "Safe": settings.SafeSearch, + "IsThemeDark": settings.IsThemeDark, + "Trans": Translate, + "HardCacheEnabled": config.DriveCacheEnabled, } - // Render the template renderTemplate(w, "text.html", data) } +func ensureFaviconIsCached(faviconID, rootURL string) { + // Check if already exists in cache + filename := fmt.Sprintf("%s_thumb.webp", faviconID) + cachedPath := filepath.Join(config.DriveCache.Path, "images", filename) + + if _, err := os.Stat(cachedPath); err == nil { + return // Already cached + } + + // Not cached, initiate download + getFaviconProxyURL("", rootURL) // This will trigger async download +} + func getTextResultsFromCacheOrFetch(cacheKey CacheKey, query, safe, lang string, page int) []TextSearchResult { cacheChan := make(chan []SearchResult) var combinedResults []TextSearchResult From 00bbb5c015bea1e6766c90ddd575fe48e0d8fdd6 Mon Sep 17 00:00:00 2001 From: partisan Date: Fri, 9 May 2025 08:42:08 +0200 Subject: [PATCH 6/6] clean up --- static/css/style-loadingcircle.css | 31 ++++++++++++++++++++++ static/js/dynamicscrollingtext.js | 12 ++++----- templates/text.html | 41 +----------------------------- 3 files changed, 37 insertions(+), 47 deletions(-) create mode 100644 static/css/style-loadingcircle.css diff --git a/static/css/style-loadingcircle.css b/static/css/style-loadingcircle.css new file mode 100644 index 0000000..ab8884f --- /dev/null +++ b/static/css/style-loadingcircle.css @@ -0,0 +1,31 @@ +.favicon-wrapper { + position: relative; + display: inline-block; + width: 16px; + height: 16px; +} + +.favicon-wrapper.loading img { + visibility: hidden; /* hide placeholder */ +} + +.favicon-wrapper.loading::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 14px; + height: 14px; + margin: -8px 0 0 -8px; + border: 2px solid var(--html-bg); + border-top-color: var(--fg); + border-radius: 50%; + animation: spin 0.7s linear infinite; + z-index: 2; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/static/js/dynamicscrollingtext.js b/static/js/dynamicscrollingtext.js index 557fc34..36a5e8a 100644 --- a/static/js/dynamicscrollingtext.js +++ b/static/js/dynamicscrollingtext.js @@ -5,8 +5,8 @@ const hardCacheEnabled = templateData.getAttribute('data-hard-cache-enabled') === 'true'; // Track all favicon/image elements and their IDs - const allMediaElements = []; - const allMediaIds = []; + let allMediaElements = []; + let allMediaIds = []; let statusCheckTimeout = null; // Add loading effects to image/favicon and associated text @@ -93,11 +93,9 @@ addLoadingEffects(imgElement); - if (hardCacheEnabled) { - imgElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; - } else { - imgElement.src = '/static/images/placeholder.svg'; - } + if (!hardCacheEnabled) { + imgElement.src = ''; // don't show anything until actual URL arrives + } // Schedule a status check if not already pending if (!statusCheckTimeout) { diff --git a/templates/text.html b/templates/text.html index 2f5f2fb..cca6630 100755 --- a/templates/text.html +++ b/templates/text.html @@ -10,52 +10,13 @@ - + -