fix(prefs): persist favicon choice and apply to HTML results
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 9s
Mirror to GitHub / mirror (push) Failing after 5s
Tests / test (push) Successful in 28s

- Save favicon cookie on POST /preferences; reflect selection in template
- Add getFaviconService helper; pass favicon service into FromResponse
- Compute ResultView.FaviconIconURL (none/google/duckduckgo/self proxy)
- Update result_item and video_item templates; add httpapi/views tests

Made-with: Cursor
This commit is contained in:
ashisgreat22 2026-03-23 23:08:21 +01:00
parent 518215f62e
commit 90ea4c9f56
7 changed files with 171 additions and 43 deletions

View file

@ -74,10 +74,10 @@
<p class="pref-desc">Fetch favicons for result URLs. "None" is most private.</p>
</div>
<select name="favicon" id="pref-favicon">
<option value="none" selected>None</option>
<option value="google">Google</option>
<option value="duckduckgo">DuckDuckGo</option>
<option value="self">Self (Kafka)</option>
<option value="none" {{if eq .FaviconService "none"}}selected{{end}}>None</option>
<option value="google" {{if eq .FaviconService "google"}}selected{{end}}>Google</option>
<option value="duckduckgo" {{if eq .FaviconService "duckduckgo"}}selected{{end}}>DuckDuckGo</option>
<option value="self" {{if eq .FaviconService "self"}}selected{{end}}>Self (Kafka)</option>
</select>
</div>
</section>

View file

@ -4,8 +4,8 @@
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.SafeTitle}}</a>
</div>
<div class="result_url">
{{if .Domain}}
<img class="result-favicon" src="/favicon/{{.Domain}}" alt="" loading="lazy" width="14" height="14">
{{if .FaviconIconURL}}
<img class="result-favicon" src="{{.FaviconIconURL}}" alt="" loading="lazy" width="14" height="14">
{{end}}
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a>
<span class="engine-badge" data-engine="{{.Engine}}">{{.Engine}}</span>

View file

@ -12,6 +12,9 @@
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.SafeTitle}}</a>
</div>
<div class="result_url">
{{if .FaviconIconURL}}
<img class="result-favicon" src="{{.FaviconIconURL}}" alt="" loading="lazy" width="14" height="14">
{{end}}
{{if .URL}}
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a>
{{end}}

View file

@ -56,16 +56,17 @@ type PageData struct {
ShowHeader bool
IsImageSearch bool
// Theme is the user's selected theme (light/dark) from cookie
Theme string
Theme string
FaviconService string
// New fields for three-column layout
Categories []string
CategoryIcons map[string]string
CategoryIcons map[string]string
DisabledCategories []string
ActiveCategory string
TimeFilters []FilterOption
TypeFilters []FilterOption
ActiveTime string
ActiveType string
ActiveCategory string
TimeFilters []FilterOption
TypeFilters []FilterOption
ActiveTime string
ActiveType string
}
// ResultView is a template-friendly wrapper around a MainResult.
@ -76,6 +77,8 @@ type ResultView struct {
TemplateName string
// Domain is the hostname extracted from the result URL, used for favicon proxying.
Domain string
// FaviconIconURL is the resolved favicon image URL for the user's favicon preference (empty = hide).
FaviconIconURL string
// SafeTitle and SafeContent are HTML-unescaped versions for rendering.
// The API returns HTML entities which Go templates escape by default.
SafeTitle template.HTML
@ -84,8 +87,8 @@ type ResultView struct {
// PageNumber represents a numbered pagination button.
type PageNumber struct {
Num int
IsCurrent bool
Num int
IsCurrent bool
}
// InfoboxView is a template-friendly infobox.
@ -102,9 +105,9 @@ type FilterOption struct {
}
var (
tmplFull *template.Template
tmplIndex *template.Template
tmplFragment *template.Template
tmplFull *template.Template
tmplIndex *template.Template
tmplFragment *template.Template
tmplPreferences *template.Template
)
@ -152,8 +155,26 @@ func OpenSearchXML(baseURL string) ([]byte, error) {
return []byte(result), nil
}
// faviconIconURL returns a safe img src for the given service and hostname, or "" for none/invalid.
func faviconIconURL(service, domain string) string {
domain = strings.TrimSpace(domain)
if domain == "" {
return ""
}
switch service {
case "google":
return "https://www.google.com/s2/favicons?domain=" + url.QueryEscape(domain) + "&sz=32"
case "duckduckgo":
return "https://icons.duckduckgo.com/ip3/" + domain + ".ico"
case "self":
return "/favicon/" + domain
default:
return ""
}
}
// FromResponse builds PageData from a search response and request params.
func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType string) PageData {
func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType, faviconService string) PageData {
// Set defaults
if activeCategory == "" {
activeCategory = "all"
@ -164,9 +185,10 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ
Pageno: pageno,
NumberOfResults: resp.NumberOfResults,
UnresponsiveEngines: resp.UnresponsiveEngines,
FaviconService: faviconService,
// New: categories with icons
Categories: []string{"all", "news", "images", "videos", "maps"},
Categories: []string{"all", "news", "images", "videos", "maps"},
DisabledCategories: []string{"shopping", "music", "weather"},
CategoryIcons: map[string]string{
"all": "🌐",
@ -220,11 +242,12 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ
}
r.Thumbnail = util.SanitizeResultURL(r.Thumbnail)
pd.Results[i] = ResultView{
MainResult: r,
TemplateName: tmplName,
Domain: domain,
SafeTitle: template.HTML(html.UnescapeString(r.Title)),
SafeContent: template.HTML(html.UnescapeString(r.Content)),
MainResult: r,
TemplateName: tmplName,
Domain: domain,
FaviconIconURL: faviconIconURL(faviconService, domain),
SafeTitle: template.HTML(html.UnescapeString(r.Title)),
SafeContent: template.HTML(html.UnescapeString(r.Content)),
}
}
@ -328,8 +351,12 @@ func RenderSearchAuto(w http.ResponseWriter, r *http.Request, data PageData) err
}
// RenderPreferences renders the full preferences page.
func RenderPreferences(w http.ResponseWriter, sourceURL, theme string) error {
func RenderPreferences(w http.ResponseWriter, sourceURL, theme, faviconService string) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
return tmplPreferences.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL, Theme: theme})
return tmplPreferences.ExecuteTemplate(w, "base", PageData{
ShowHeader: true,
SourceURL: sourceURL,
Theme: theme,
FaviconService: faviconService,
})
}

View file

@ -1,6 +1,7 @@
package views
import (
"strings"
"testing"
"github.com/metamorphosis-dev/samsa/internal/contracts"
@ -37,7 +38,7 @@ func mockEmptyResponse() contracts.SearchResponse {
func TestFromResponse_Basic(t *testing.T) {
resp := mockSearchResponse("samsa trial", 42)
data := FromResponse(resp, "samsa trial", 1, "", "", "")
data := FromResponse(resp, "samsa trial", 1, "", "", "", "none")
if data.Query != "samsa trial" {
t.Errorf("expected query 'samsa trial', got %q", data.Query)
@ -55,7 +56,7 @@ func TestFromResponse_Basic(t *testing.T) {
func TestFromResponse_Pagination(t *testing.T) {
resp := mockSearchResponse("test", 100)
data := FromResponse(resp, "test", 3, "", "", "")
data := FromResponse(resp, "test", 3, "", "", "", "none")
if data.PrevPage != 2 {
t.Errorf("expected PrevPage 2, got %d", data.PrevPage)
@ -80,7 +81,7 @@ func TestFromResponse_Pagination(t *testing.T) {
}
func TestFromResponse_Empty(t *testing.T) {
data := FromResponse(mockEmptyResponse(), "", 1, "", "", "")
data := FromResponse(mockEmptyResponse(), "", 1, "", "", "", "none")
if data.NumberOfResults != 0 {
t.Errorf("expected 0 results, got %d", data.NumberOfResults)
@ -90,6 +91,31 @@ func TestFromResponse_Empty(t *testing.T) {
}
}
func TestFromResponse_FaviconIconURL(t *testing.T) {
u := "https://example.com/path"
resp := contracts.SearchResponse{
Query: "q",
NumberOfResults: 1,
Results: []contracts.MainResult{{Title: "t", URL: &u, Engine: "bing"}},
Answers: []map[string]any{},
Corrections: []string{},
Infoboxes: []map[string]any{},
Suggestions: []string{},
UnresponsiveEngines: [][2]string{},
}
data := FromResponse(resp, "q", 1, "", "", "", "google")
if len(data.Results) != 1 {
t.Fatalf("expected 1 result, got %d", len(data.Results))
}
got := data.Results[0].FaviconIconURL
if got == "" || !strings.Contains(got, "google.com/s2/favicons") {
t.Fatalf("expected google favicon URL, got %q", got)
}
if !strings.Contains(got, "example.com") {
t.Fatalf("expected domain in favicon URL, got %q", got)
}
}
func TestIsHTMXRequest(t *testing.T) {
tests := []struct {
name string