// kafka — a privacy-respecting metasearch engine // Copyright (C) 2026-present metamorphosis-dev // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package views import ( "embed" "html/template" "io/fs" "net/http" "strconv" "strings" "github.com/metamorphosis-dev/kafka/internal/contracts" ) //go:embed all:templates var templatesFS embed.FS //go:embed all:static var staticFS embed.FS // PageData holds all data passed to templates. type PageData struct { SourceURL string Query string Pageno int PrevPage int NextPage int HasNext bool NumberOfResults int Results []ResultView Answers []string Corrections []string Suggestions []string Infoboxes []InfoboxView UnresponsiveEngines [][2]string PageNumbers []PageNumber ShowHeader bool } // ResultView is a template-friendly wrapper around a MainResult. type ResultView struct { contracts.MainResult // TemplateName is the actual template to dispatch to, computed from Template. // "videos" maps to "video_item", everything else maps to "result_item". TemplateName string } // PageNumber represents a numbered pagination button. type PageNumber struct { Num int IsCurrent bool } // InfoboxView is a template-friendly infobox. type InfoboxView struct { Title string Content string ImgSrc string } var ( tmplFull *template.Template tmplIndex *template.Template tmplFragment *template.Template ) func init() { // Strip the leading "templates/" prefix from embed paths. tmplFS, _ := fs.Sub(templatesFS, "templates") funcMap := template.FuncMap{ "urlquery": template.URLQueryEscaper, } tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", )) tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "base.html", "index.html", )) tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "results_inner.html", "result_item.html", "video_item.html", )) } // StaticFS returns the embedded static file system for serving CSS/JS/images. func StaticFS() (fs.FS, error) { return fs.Sub(staticFS, "static") } // OpenSearchXML returns the OpenSearch description XML with {baseUrl} // replaced by the provided base URL. func OpenSearchXML(baseURL string) ([]byte, error) { tmplFS, _ := fs.Sub(templatesFS, "templates") data, err := fs.ReadFile(tmplFS, "opensearch.xml") if err != nil { return nil, err } result := strings.ReplaceAll(string(data), "{baseUrl}", baseURL) return []byte(result), nil } // FromResponse builds PageData from a search response and request params. func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageData { pd := PageData{ Query: query, Pageno: pageno, NumberOfResults: resp.NumberOfResults, UnresponsiveEngines: resp.UnresponsiveEngines, } // Convert results. pd.Results = make([]ResultView, len(resp.Results)) for i, r := range resp.Results { tmplName := "result_item" if r.Template == "videos" { tmplName = "video_item" } pd.Results[i] = ResultView{MainResult: r, TemplateName: tmplName} } // Convert answers (they're map[string]any — extract string values). for _, a := range resp.Answers { if s, ok := a["answer"].(string); ok && s != "" { pd.Answers = append(pd.Answers, s) } } pd.Corrections = resp.Corrections pd.Suggestions = resp.Suggestions // Convert infoboxes. for _, ib := range resp.Infoboxes { iv := InfoboxView{} if v, ok := ib["infobox"].(string); ok { iv.Content = v } if v, ok := ib["title"].(string); ok { iv.Title = v } if v, ok := ib["img_src"].(string); ok { iv.ImgSrc = v } if iv.Title != "" || iv.Content != "" { pd.Infoboxes = append(pd.Infoboxes, iv) } } // Pagination. pd.PrevPage = pageno - 1 if pd.PrevPage < 1 { pd.PrevPage = 1 } pd.NextPage = pageno + 1 // Assume there are more results if we got results on this page. pd.HasNext = len(resp.Results) > 0 // Build page number list. pstart := 1 pend := 10 if pageno > 5 { pstart = pageno - 4 pend = pageno + 5 } if pstart < 1 { pstart = 1 } for x := pstart; x <= pend; x++ { pd.PageNumbers = append(pd.PageNumbers, PageNumber{Num: x, IsCurrent: x == pageno}) } return pd } // RenderIndex renders the homepage (search box only). func RenderIndex(w http.ResponseWriter, sourceURL string) error { w.Header().Set("Content-Type", "text/html; charset=utf-8") return tmplIndex.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL}) } // RenderSearch renders the full search results page (with base layout). func RenderSearch(w http.ResponseWriter, data PageData) error { w.Header().Set("Content-Type", "text/html; charset=utf-8") data.ShowHeader = true return tmplFull.ExecuteTemplate(w, "base", data) } // RenderSearchFragment renders only the results fragment for HTMX requests. func RenderSearchFragment(w http.ResponseWriter, data PageData) error { w.Header().Set("Content-Type", "text/html; charset=utf-8") return tmplFragment.ExecuteTemplate(w, "results_inner", data) } // IsHTMXRequest checks if the request is an HTMX partial request. func IsHTMXRequest(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } // FormatQuery parses format, pageno, and q from an HTTP request. func FormatQuery(r *http.Request) (format string, pageno int, query string) { format = r.FormValue("format") if format == "" { format = "html" } pageno, _ = strconv.Atoi(r.FormValue("pageno")) if pageno < 1 { pageno = 1 } query = strings.TrimSpace(r.FormValue("q")) return } // RenderSearch decides between full page or fragment based on HTMX header. func RenderSearchAuto(w http.ResponseWriter, r *http.Request, data PageData) error { if IsHTMXRequest(r) { return RenderSearchFragment(w, data) } return RenderSearch(w, data) } var _ = strconv.Itoa