feat: support faceted navigation of search results
8 files changed, 305 insertions(+), 112 deletions(-)
M frontend/static/search.js → frontend/static/search.js
@@ -4,6 +4,7 @@ const queryInput = document.getElementById("query"); const dialog = document.getElementById("dialog"); const results = document.getElementById("results"); let pagination = document.getElementById("pagination"); +let facets = document.querySelector(".facets"); const resultsRange = new Range(); resultsRange.setStart(results, 0);@@ -58,7 +59,7 @@ /** * * @param {MouseEvent} ev */ -function paginationLinkClicked(ev) { +function updateSearch(ev) { const url = new URL(ev.target.href); getResults(url); ev.preventDefault();@@ -66,24 +67,33 @@ } /** * - * @param {HTMLElement} pagination + * @param {HTMLElement} parent */ -function addPaginationEventListeners(pagination) { - Array.from(pagination.children).forEach((child) => - child.addEventListener("click", paginationLinkClicked), - ); +function addUpdateSearchListeners(parent) { + parent + .querySelectorAll("a") + .forEach((child) => child.addEventListener("click", updateSearch)); } +/** + * @param {string} html + */ function renderResults(html) { const fragment = resultsRange.createContextualFragment( escapePolicy !== null ? escapePolicy.createHTML(html) : html, ); - pagination = fragment.querySelector("#pagination"); resultsRange.deleteContents(); resultsRange.insertNode(fragment); addOpenDialogListeners(results); + + pagination = results.querySelector("#pagination"); if (pagination !== null) { - addPaginationEventListeners(pagination); + addUpdateSearchListeners(pagination); + } + + facets = results.querySelector(".facets"); + if (facets !== null) { + addUpdateSearchListeners(facets); } }@@ -141,9 +151,12 @@ }); if (results !== null) { addOpenDialogListeners(results); -} -if (pagination !== null) { - addPaginationEventListeners(pagination); + if (pagination !== null) { + addUpdateSearchListeners(pagination); + } + if (facets !== null) { + addUpdateSearchListeners(facets); + } } document.querySelector("a.current").addEventListener("click", function (ev) {
M frontend/static/style.css → frontend/static/style.css
@@ -62,6 +62,15 @@ } form { margin: 1.5rem 0; + display: grid; + align-items: start; + grid-template-columns: 16em 1fr; + column-gap: 1rem; + row-gap: 1rem; +} + +form > search { + grid-column: 1 / 3; } fieldset {@@ -70,6 +79,12 @@ column-gap: 1ex; border: none; padding: unset; margin: unset; +} + +.facet-queries { + display: flex; + flex-direction: column; + gap: 0.5rem; } legend {@@ -131,7 +146,9 @@ a[rel="next"] { margin-left: auto; } } -section > footer { + +#results footer { + grid-column: 1 / 3; text-align: center; }@@ -167,8 +184,49 @@ dialog > h2 { margin-top: 0.5rem; } +#results { + display: contents; +} + +#results .notice { + grid-column: 1 / 3; +} + +.facets { + background-color: var(--accent-bg); + border: 1px solid var(--border); + padding: 0.25rem 0.5rem; + box-sizing: border-box; +} + +.facets h3 { + margin-top: 0 !important; + font-size: 1rem; + padding: 0.25rem 0; +} + +.facets ul { + list-style: none; + padding: 0; + margin: 0; + margin-bottom: 1ex; +} + +.facet .term { + display: flex; + justify-content: space-between; + border-bottom: 1px solid transparent; + overflow-wrap: break-word; + padding: 0.25rem 0; + margin-right: 0; +} + +.term .count { + font-variant-numeric: tabular-nums; +} + table { - margin-top: 0; + margin: 0; width: 100%; table-layout: fixed; white-space: nowrap;
M internal/components/data.go → internal/components/data.go
@@ -2,6 +2,7 @@ package components import ( "net/url" + "slices" "strconv" "alin.ovh/searchix/frontend"@@ -23,8 +24,9 @@ } type ResultData struct { TemplateData - Query string - Results *search.Result + Query string + FacetQueries url.Values + Results *search.Result } type SearchNav struct {@@ -68,3 +70,27 @@ } return u } + +func (u SearchNav) HasFacet(field string, term string) bool { + return slices.Contains(u.URL.Query()[field], term) +} + +func (u SearchNav) AddFacet(field string, term string) string { + nu := u.URL + q := nu.Query() + q.Add(field, term) + nu.RawQuery = q.Encode() + + return nu.String() +} + +func (u SearchNav) RemoveFacet(field string, term string) string { + nu := u.URL + q := nu.Query() + q[field] = slices.DeleteFunc(q[field], func(v string) bool { + return v == term + }) + nu.RawQuery = q.Encode() + + return nu.String() +}
M internal/components/results.go → internal/components/results.go
@@ -1,6 +1,11 @@ package components import ( + "maps" + "slices" + + "github.com/blevesearch/bleve/v2/search" + "alin.ovh/searchix/internal/config" g "alin.ovh/gomponents"@@ -29,6 +34,7 @@ content = Combined(r.Results) } return g.Group([]g.Node{ + Facets(r), content, Footer( g.Attr("aria-label", "pagination"),@@ -51,6 +57,56 @@ A(Href(r.SearchNav.All), g.Text("Show All")), ), ), }) +} + +func Facets(r ResultData) g.Node { + return Nav( + Class("facets"), + g.Map(slices.Sorted(maps.Keys(r.Results.Facets)), func(name string) g.Node { + facet := r.Results.Facets[name] + + return Div(Class("facet"), + H3(g.Text(name)), + Ul( + g.Map(facet.Terms.Terms(), func(term *search.TermFacet) g.Node { + t := term.Term + if t == "" { + t = "None" + } + + return Term( + g.If( + r.SearchNav.HasFacet(facet.Field, term.Term), + A( + Href(r.SearchNav.RemoveFacet(facet.Field, term.Term)), + TitleAttr("remove"), + g.Text(t), + ), + A( + Href(r.SearchNav.AddFacet(facet.Field, term.Term)), + g.Text(t), + ), + ), + Span( + Class("count"), + g.Textf("%d", term.Count), + ), + ) + }), + g.If(facet.Missing > 0, + Li(Class("term"), + A(Href(r.SearchNav.AddFacet(facet.Field, "")), g.Text("None")), + Span(Class("count"), g.Textf("%d", facet.Missing)), + ), + ), + ), + ) + }), + ) +} + +func Term(children ...g.Node) g.Node { + return Li(Class("term"), g.Group(children)) } func ResultsPage(r ResultData) g.Node {
M internal/components/search.go → internal/components/search.go
@@ -12,34 +12,37 @@ "alin.ovh/searchix/internal/config" "alin.ovh/searchix/internal/importer" ) -func SearchForm(tdata TemplateData, r ResultData) g.Node { - return Search(Form( +func SearchForm(tdata TemplateData, r ResultData, children ...g.Node) g.Node { + return Form( ID("search"), - FieldSet( - Legend( - ID("legend"), - H2(g.Textf("%s search", sourceNameAndType(tdata.Source))), - A( - Class("help"), - Target("_blank"), - Href("https://blevesearch.com/docs/Query-String-Query/"), - g.Text("advanced query syntax"), + Search( + FieldSet( + Legend( + ID("legend"), + H2(g.Textf("%s search", sourceNameAndType(tdata.Source))), + A( + Class("help"), + Target("_blank"), + Href("https://blevesearch.com/docs/Query-String-Query/"), + g.Text("advanced query syntax"), + ), + ), + Input( + ID("query"), + Aria("labelledby", "legend"), + MinLength("2"), + Name("query"), + Type("search"), + Value(r.Query), + AutoFocus(), + g.Attr("spellcheck", "false"), + g.Attr("autocapitalize", "none"), ), + Button(g.Text("Search")), ), - Input( - ID("query"), - Aria("labelledby", "legend"), - MinLength("2"), - Name("query"), - Type("search"), - Value(r.Query), - AutoFocus(), - g.Attr("spellcheck", "false"), - g.Attr("autocapitalize", "none"), - ), - Button(g.Text("Search")), ), - )) + g.Group(children), + ) } func SearchPage(tdata TemplateData, r ResultData, children ...g.Node) g.Node {@@ -97,12 +100,13 @@ g.Text("."), ), ), script(tdata.Assets.ByPath["/search.js"]), - SearchForm(tdata, r), - Section( - ID("results"), - Role("list"), - Aria("label", "search results"), - g.Group(children), + SearchForm(tdata, r, + Div( + ID("results"), + Role("list"), + Aria("label", "search results"), + g.Group(children), + ), ), Dialog( ID("dialog"),
M internal/index/search.go → internal/index/search.go
@@ -5,6 +5,7 @@ "bytes" "context" "encoding/gob" "iter" + "net/url" "strings" "time"@@ -50,7 +51,7 @@ return index.meta.LastUpdated() } func (index *ReadIndex) GetEnabledSources() ([]string, error) { - facet := bleve.NewFacetRequest("Source", 100) + facet := bleve.NewFacetRequest("Source", len(index.config.Importer.Sources)) query := bleve.NewMatchAllQuery() search := bleve.NewSearchRequest(query) search.AddFacet("Source", facet)@@ -136,21 +137,10 @@ source *config.Source, keyword string, from int, pageSize int, + facets url.Values, ) (*Result, error) { query := bleve.NewBooleanQuery() - if strings.ContainsAny(keyword, "+-=&|<>!(){}[]^\"~*?:\\/") { - query.AddMust(bleve.NewQueryStringQuery(keyword)) - } else { - // match the user's query in any field ... - query.AddMust(bleve.NewDisjunctionQuery( - setBoost(bleve.NewTermQuery(keyword), 50), - setBoost(bleve.NewPrefixQuery(keyword), 25), - setBoost(bleve.NewMatchPhraseQuery(keyword), 25), - bleve.NewMatchQuery(keyword), - )) - } - if source != nil { query.AddMust( setField(bleve.NewTermQuery(source.Key), "Source"),@@ -164,69 +154,105 @@ query.AddShould(q) } - idQuery := bleve.NewMatchQuery(keyword) - idQuery.SetField("_id") - idQuery.SetBoost(8) - query.AddShould(idQuery) + if keyword != "" { + if strings.ContainsAny(keyword, "+-=&|<>!(){}[]^\"~*?:\\/") { + query.AddMust(bleve.NewQueryStringQuery(keyword)) + } else { + // match the user's query in any field ... + query.AddMust(bleve.NewDisjunctionQuery( + setBoost(bleve.NewTermQuery(keyword), 50), + setBoost(bleve.NewPrefixQuery(keyword), 25), + setBoost(bleve.NewMatchPhraseQuery(keyword), 25), + bleve.NewMatchQuery(keyword), + )) + } - mainProgramQuery := bleve.NewMatchQuery(keyword) - mainProgramQuery.SetField("MainProgram") - mainProgramQuery.SetBoost(10) - query.AddShould(mainProgramQuery) + idQuery := bleve.NewMatchQuery(keyword) + idQuery.SetField("_id") + idQuery.SetBoost(8) + query.AddShould(idQuery) - mainProgramLiteralQuery := bleve.NewTermQuery(keyword) - mainProgramLiteralQuery.SetField("MainProgram") - mainProgramLiteralQuery.SetBoost(50) - query.AddShould(mainProgramLiteralQuery) + mainProgramQuery := bleve.NewMatchQuery(keyword) + mainProgramQuery.SetField("MainProgram") + mainProgramQuery.SetBoost(10) + query.AddShould(mainProgramQuery) - programsQuery := bleve.NewMatchQuery(keyword) - programsQuery.SetField("Programs") - programsQuery.SetBoost(2) - query.AddShould(programsQuery) + mainProgramLiteralQuery := bleve.NewTermQuery(keyword) + mainProgramLiteralQuery.SetField("MainProgram") + mainProgramLiteralQuery.SetBoost(50) + query.AddShould(mainProgramLiteralQuery) - programsLiteralQuery := bleve.NewTermQuery(keyword) - programsLiteralQuery.SetField("Programs") - programsLiteralQuery.SetBoost(100) - query.AddShould(programsLiteralQuery) + programsQuery := bleve.NewMatchQuery(keyword) + programsQuery.SetField("Programs") + programsQuery.SetBoost(2) + query.AddShould(programsQuery) - attrQuery := bleve.NewMatchQuery(keyword) - attrQuery.SetField("Attribute") - attrQuery.SetBoost(2) - query.AddShould(attrQuery) + programsLiteralQuery := bleve.NewTermQuery(keyword) + programsLiteralQuery.SetField("Programs") + programsLiteralQuery.SetBoost(100) + query.AddShould(programsLiteralQuery) - attrPrefixQuery := bleve.NewPrefixQuery(keyword) - attrPrefixQuery.SetField("Attribute") - attrPrefixQuery.SetBoost(10) - query.AddShould(attrPrefixQuery) + attrQuery := bleve.NewMatchQuery(keyword) + attrQuery.SetField("Attribute") + attrQuery.SetBoost(2) + query.AddShould(attrQuery) - attrTermQuery := bleve.NewTermQuery(keyword) - attrTermQuery.SetField("Attribute") - attrTermQuery.SetBoost(600) - query.AddShould(attrTermQuery) + attrPrefixQuery := bleve.NewPrefixQuery(keyword) + attrPrefixQuery.SetField("Attribute") + attrPrefixQuery.SetBoost(10) + query.AddShould(attrPrefixQuery) - nameQuery := bleve.NewMatchQuery(keyword) - nameQuery.SetField("Name") - nameQuery.SetBoost(2) - query.AddShould(nameQuery) + attrTermQuery := bleve.NewTermQuery(keyword) + attrTermQuery.SetField("Attribute") + attrTermQuery.SetBoost(600) + query.AddShould(attrTermQuery) - nameNGramQuery := bleve.NewMatchQuery(keyword) - nameNGramQuery.SetField("NameNGram") - nameNGramQuery.SetBoost(2) - query.AddShould(nameNGramQuery) + nameQuery := bleve.NewMatchQuery(keyword) + nameQuery.SetField("Name") + nameQuery.SetBoost(2) + query.AddShould(nameQuery) - namePrefixQuery := bleve.NewPrefixQuery(keyword) - namePrefixQuery.SetField("Name") - namePrefixQuery.SetBoost(10) - query.AddShould(namePrefixQuery) + nameNGramQuery := bleve.NewMatchQuery(keyword) + nameNGramQuery.SetField("NameNGram") + nameNGramQuery.SetBoost(2) + query.AddShould(nameNGramQuery) - nameTermQuery := bleve.NewTermQuery(keyword) - nameTermQuery.SetField("Name") - nameTermQuery.SetBoost(100) - query.AddShould(nameTermQuery) + namePrefixQuery := bleve.NewPrefixQuery(keyword) + namePrefixQuery.SetField("Name") + namePrefixQuery.SetBoost(10) + query.AddShould(namePrefixQuery) + + nameTermQuery := bleve.NewTermQuery(keyword) + nameTermQuery.SetField("Name") + nameTermQuery.SetBoost(100) + query.AddShould(nameTermQuery) + } search := bleve.NewSearchRequest(query) search.Explain = config.DevMode search.Size = pageSize + + if len(facets) > 0 { + for name, values := range facets { + or := bleve.NewDisjunctionQuery() + for _, value := range values { + q := bleve.NewTermQuery(value) + q.SetField(name) + + or.AddQuery(q) + } + query.AddMust(or) + } + + if keyword == "" { + search.SortBy([]string{"_id"}) + } + } + + if source == nil || source.Importer == config.Packages { + search.AddFacet("Package set", bleve.NewFacetRequest("PackageSet", 10)) + search.AddFacet("Platform", bleve.NewFacetRequest("Platforms", 10)) + } if from != 0 { search.From = from
M internal/index/search_test.go → internal/index/search_test.go
@@ -68,6 +68,7 @@ source, "git", 0, 100, + nil, ) if err != nil { t.Fatal(err)@@ -112,6 +113,7 @@ source, "jj", 0, 100, + nil, ) if err != nil { t.Fatal(err)
M internal/server/mux.go → internal/server/mux.go
@@ -94,11 +94,18 @@ return } } - if r.URL.Query().Has("query") { + facets := r.URL.Query() + facets.Del("query") + facets.Del("page") + + if r.URL.Query().Has("query") || len(facets) > 0 { qs := r.URL.Query().Get("query") - ctx := fctx.WithMeta(r.Context(), "search_query", qs) + ctx := r.Context() + if qs != "" { + ctx = fctx.WithMeta(ctx, "search_query", qs) + } - if len(qs) < 2 { + if len(qs) < 2 && len(facets) == 0 { errorHandler(w, r, "Query too short", http.StatusBadRequest) return@@ -121,7 +128,7 @@ } page := pagination.New(pageNumber, pageSize) ctx, cancel := context.WithTimeout(ctx, cfg.Web.SearchTimeout.Duration) - results, err := index.Search(ctx, source, qs, page.From, page.Size) + results, err := index.Search(ctx, source, qs, page.From, page.Size, facets) cancel() if err != nil {@@ -151,8 +158,9 @@ Assets: assets, Query: qs, SearchNav: components.NewSearchNav(*r.URL).WithPagination(page), }, - Query: qs, - Results: results, + Query: qs, + FacetQueries: facets, + Results: results, } w.Header().Add("Cache-Control", "max-age=300")