~alanpearce/searchix

6781684647ad7f131054a93e1d13105d865c0e83 — Alan Pearce 4 months ago 4423458
feat: enable combined search
A internal/components/combined.templ => internal/components/combined.templ +38 -0
@@ 0,0 1,38 @@
package components

import (
	"go.alanpearce.eu/searchix/internal/index"
	"go.alanpearce.eu/searchix/internal/nix"
)

templ Combined(result *index.Result) {
	<table>
		<thead>
			<tr>
				<th scope="col">Attribute</th>
				<th scope="col">Description</th>
			</tr>
		</thead>
		<tbody>
			for _, hit := range result.Hits {
				<tr>
					<td>
						@openCombinedDialogLink(nix.GetKey(hit.Data))
					</td>
					<td>
						switch hit.Data.(type) {
							case nix.Option:
								if o := convertMatch[nix.Option](hit.Data); o != nil {
									@markdown(firstSentence(o.Description))
								}
							case nix.Package:
								if o := convertMatch[nix.Package](hit.Data); o != nil {
									{ firstSentence(o.Description) }
								}
						}
					</td>
				</tr>
			}
		</tbody>
	</table>
}

M internal/components/data.go => internal/components/data.go +1 -2
@@ 11,9 11,8 @@ import (

type TemplateData struct {
	Sources       []*config.Source
	Source        config.Source
	Source        *config.Source
	Query         string
	Results       bool
	SourceResult  *bleve.SearchResult
	ExtraHeadHTML string
	Code          int

M internal/components/page.templ => internal/components/page.templ +23 -3
@@ 18,11 18,17 @@ templ Page(tdata TemplateData) {
				<link href={ sheet.URL } rel="stylesheet" integrity={ "sha256-" + sheet.Base64SHA256 }/>
			}
			@Unsafe(tdata.ExtraHeadHTML)
			<link
				rel="search"
				type="application/opensearchdescription+xml"
				title={ "Searchix " + sourceNameAndType(nil) }
				href={ string(joinPath("/all", "opensearch.xml")) }
			/>
			for _, source := range tdata.Sources {
				<link
					rel="search"
					type="application/opensearchdescription+xml"
					title={ "Searchix " + sourceNameAndType(*source) }
					title={ "Searchix " + sourceNameAndType(source) }
					href={ string(joinPath("/", source.Importer.String(), source.Key, "opensearch.xml")) }
				/>
			}


@@ 31,9 37,19 @@ templ Page(tdata TemplateData) {
			<header>
				<nav>
					<h1><a href="/">Searchix</a></h1>
					<a
						if tdata.Source == nil {
							if tdata.SourceResult != nil || tdata.Query != "" {
								class="current"
							}
							href="/all/search"
						} else {
							href={ joinPathQuery("/all/search", tdata.Query) }
						}
					>All</a>
					for _, source := range tdata.Sources {
						<a
							if tdata.Source.Name == source.Name {
							if tdata.Source != nil && tdata.Source.Name == source.Name {
								class="current"
								href={ joinPath("/", source.Importer.String(), source.Key, "search") }
							} else {


@@ 65,7 81,11 @@ func Unsafe(html string) templ.Component {
	})
}

func sourceNameAndType(source config.Source) string {
func sourceNameAndType(source *config.Source) string {
	if source == nil {
		return "Combined"
	}

	switch source.Importer {
	case config.Options:
		return source.Name + " " + source.Importer.String()

M internal/components/results.templ => internal/components/results.templ +14 -5
@@ 3,6 3,7 @@ package components
import (
	"strconv"
	"go.alanpearce.eu/searchix/internal/nix"
	"go.alanpearce.eu/searchix/internal/config"
)

func convertMatch[I nix.Importable](m nix.Importable) *I {


@@ 16,11 17,15 @@ func convertMatch[I nix.Importable](m nix.Importable) *I {
templ Results(r ResultData) {
	if r.Query != "" {
		if r.Results != nil && r.Results.Total > 0 {
			switch r.Results.Hits[0].Data.(type) {
				case nix.Option:
					@Options(r.Results)
				case nix.Package:
					@Packages(r.Results)
			if r.Source != nil {
				switch r.Source.Importer {
					case config.Options:
						@Options(r.Results)
					case config.Packages:
						@Packages(r.Results)
				}
			} else {
				@Combined(r.Results)
			}
			<footer aria-label="pagination">
				<nav id="pagination">


@@ 50,3 55,7 @@ templ ResultsPage(r ResultData) {
templ openDialogLink(attr string) {
	<a class="open-dialog" href={ templ.SafeURL(attr) }>{ attr }</a>
}

templ openCombinedDialogLink(attr string) {
	<a class="open-dialog" href={ templ.SafeURL("/" + attr) }>{ attr }</a>
}

M internal/config/importer-type.go => internal/config/importer-type.go +6 -1
@@ 9,13 9,16 @@ import (
type ImporterType int

const (
	UnknownType = iota
	All = iota - 1
	UnknownType
	Packages
	Options
)

func (i ImporterType) String() string {
	switch i {
	case All:
		return "combined"
	case Packages:
		return "packages"
	case Options:


@@ 27,6 30,8 @@ func (i ImporterType) String() string {

func (i ImporterType) Singular() string {
	switch i {
	case All:
		return "combined"
	case Packages:
		return "package"
	case Options:

M internal/index/search.go => internal/index/search.go +35 -34
@@ 19,7 19,7 @@ import (
const ResultsPerPage = 20

type DocumentMatch struct {
	search.DocumentMatch
	*search.DocumentMatch
	Data nix.Importable
}



@@ 53,9 53,18 @@ func (index *ReadIndex) GetEnabledSources() ([]string, error) {
	return enabledSources, nil
}

func (index *ReadIndex) GetSource(ctx context.Context, name string) (*bleve.SearchResult, error) {
	query := bleve.NewTermQuery(name)
	query.SetField("Source")
func (index *ReadIndex) GetSource(
	ctx context.Context,
	source *config.Source,
) (*bleve.SearchResult, error) {
	var query query.Query
	if source == nil {
		query = bleve.NewMatchAllQuery()
	} else {
		tq := bleve.NewTermQuery(source.Name)
		tq.SetField("Source")
		query = tq
	}
	search := bleve.NewSearchRequest(query)

	result, err := index.index.SearchInContext(ctx, search)


@@ 68,7 77,7 @@ func (index *ReadIndex) GetSource(ctx context.Context, name string) (*bleve.Sear
			return nil, errors.WithMessagef(
				err,
				"failed to execute search to find source %s in index",
				name,
				source,
			)
		}
	}


@@ 89,7 98,7 @@ func (index *ReadIndex) search(
	ctx context.Context,
	request *bleve.SearchRequest,
) (*Result, error) {
	request.Fields = []string{"_data"}
	request.Fields = []string{"_data", "Source"}

	bleveResult, err := index.index.SearchInContext(ctx, request)
	select {


@@ 103,6 112,7 @@ func (index *ReadIndex) search(
		results := make([]DocumentMatch, min(ResultsPerPage, bleveResult.Total))
		var buf bytes.Buffer
		for i, result := range bleveResult.Hits {
			results[i].DocumentMatch = bleveResult.Hits[i]
			_, err = buf.WriteString(result.Fields["_data"].(string))
			if err != nil {
				return nil, errors.WithMessage(err, "error fetching result data")


@@ 133,37 143,28 @@ func (index *ReadIndex) Search(
	userQuery := bleve.NewMatchQuery(keyword)
	userQuery.Analyzer = "option_name"

	query.AddMust(
		setField(bleve.NewTermQuery(source.Key), "Source"),
		userQuery,
	)

	switch source.Importer {
	case config.Packages:
		// ...and boost it if it matches any of these
		query.AddShould(
			setField(bleve.NewMatchQuery(keyword), "MainProgram"),
			setField(bleve.NewMatchQuery(keyword), "Name"),
			setField(bleve.NewMatchQuery(keyword), "Attribute"),
		)
	case config.Options:
		query.AddShould(
			setField(bleve.NewMatchQuery(keyword), "Loc"),
			setField(bleve.NewMatchQuery(keyword), "Name"),
		)
		nameLiteralQuery := bleve.NewMatchQuery(keyword)
		nameLiteralQuery.SetField("Name")
		nameLiteralQuery.Analyzer = standard.Name
		query.AddShould(
			nameLiteralQuery,
		)
	default:
		return nil, errors.Errorf(
			"unsupported source type for search: %s",
			source.Importer.String(),
	if source != nil {
		query.AddMust(
			setField(bleve.NewTermQuery(source.Key), "Source"),
			userQuery,
		)
	}

	// ...and boost it if it matches any of these
	query.AddShould(
		setField(bleve.NewMatchQuery(keyword), "MainProgram"),
		setField(bleve.NewMatchQuery(keyword), "Name"),
		setField(bleve.NewMatchQuery(keyword), "Attribute"),
	)
	query.AddShould(
		setField(bleve.NewMatchQuery(keyword), "Loc"),
		setField(bleve.NewMatchQuery(keyword), "Name"),
	)
	nameLiteralQuery := bleve.NewMatchQuery(keyword)
	nameLiteralQuery.SetField("Name")
	nameLiteralQuery.Analyzer = standard.Name
	query.AddShould(nameLiteralQuery)

	search := bleve.NewSearchRequest(query)
	search.Size = ResultsPerPage


M internal/server/mux.go => internal/server/mux.go +41 -9
@@ 89,11 89,14 @@ func NewMux(
	createSearchHandler := func(importerType config.ImporterType) func(http.ResponseWriter, *http.Request) {
		return func(w http.ResponseWriter, r *http.Request) {
			var err error
			source := cfg.Importer.Sources[r.PathValue("source")]
			if source == nil || importerType != source.Importer {
				errorHandler(w, r, http.StatusText(http.StatusNotFound), http.StatusNotFound)
			var source *config.Source
			if importerType != config.All {
				source = cfg.Importer.Sources[r.PathValue("source")]
				if source == nil || importerType != source.Importer {
					errorHandler(w, r, http.StatusText(http.StatusNotFound), http.StatusNotFound)

				return
					return
				}
			}

			ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)


@@ 127,7 130,7 @@ func NewMux(
				tdata := components.ResultData{
					TemplateData: components.TemplateData{
						ExtraHeadHTML: cfg.Web.ExtraHeadHTML,
						Source:        *source,
						Source:        source,
						Sources:       sources,
						Assets:        frontend.Assets,
						Query:         qs,


@@ 181,19 184,18 @@ func NewMux(
					errorHandler(w, r, err.Error(), http.StatusInternalServerError)
				}
			} else {
				sourceResult, err := index.GetSource(ctx, source.Key)
				sourceResult, err := index.GetSource(ctx, source)
				if err != nil {
					errorHandler(w, r, err.Error(), http.StatusInternalServerError)

					return
				}

				w.Header().Add("Cache-Control", "max-age=14400")
				err = components.SearchPage(
					components.TemplateData{
						ExtraHeadHTML: cfg.Web.ExtraHeadHTML,
						Sources:       sources,
						Source:        *source,
						Source:        source,
						SourceResult:  sourceResult,
						Assets:        frontend.Assets,
					},


@@ 208,6 210,7 @@ func NewMux(
		}
	}

	mux.HandleFunc("/all/search", createSearchHandler(config.All))
	mux.HandleFunc("/options/{source}/search", createSearchHandler(config.Options))
	mux.HandleFunc("/packages/{source}/search", createSearchHandler(config.Packages))



@@ 245,7 248,7 @@ func NewMux(
			tdata := components.DocumentData{
				TemplateData: components.TemplateData{
					ExtraHeadHTML: cfg.Web.ExtraHeadHTML,
					Source:        *source,
					Source:        source,
					Sources:       sources,
					Assets:        frontend.Assets,
				},


@@ 265,6 268,8 @@ func NewMux(
	}
	mux.HandleFunc("/options/{source}/{id}", createSourceIDHandler(config.Options))
	mux.HandleFunc("/packages/{source}/{id}", createSourceIDHandler(config.Packages))
	mux.HandleFunc("/option/{source}/{id}", createSourceIDHandler(config.Options))
	mux.HandleFunc("/package/{source}/{id}", createSourceIDHandler(config.Packages))

	createOpenSearchXMLHandler := func(importerType config.ImporterType) func(http.ResponseWriter, *http.Request) {
		return func(w http.ResponseWriter, r *http.Request) {


@@ 312,6 317,33 @@ func NewMux(

	mux.HandleFunc("/options/{source}/opensearch.xml", createOpenSearchXMLHandler(config.Options))
	mux.HandleFunc("/packages/{source}/opensearch.xml", createOpenSearchXMLHandler(config.Packages))
	mux.HandleFunc("/all/opensearch.xml", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Add("Cache-Control", "max-age=604800")
		w.Header().Set("Content-Type", "application/opensearchdescription+xml")
		osd := &opensearch.Description{
			ShortName:   "Searchix Combined",
			LongName:    "Search nix options and packages with Searchix",
			Description: "Search nix options and packages with Searchix",
			SearchForm:  cfg.Web.BaseURL.JoinPath("all/search"),
			URL: opensearch.URL{
				Method: "get",
				Type:   "text/html",
				Template: cfg.Web.BaseURL.JoinPath("all/search").
					AddRawQuery("query", "{searchTerms}"),
			},
		}
		enc := xml.NewEncoder(w)
		enc.Indent("", "    ")
		err := enc.Encode(osd)
		if err != nil {
			// no errorHandler; HTML does not make sense here
			http.Error(
				w,
				fmt.Sprintf("OpenSearch XML encoding error: %v", err),
				http.StatusInternalServerError,
			)
		}
	})

	mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
		asset, found := frontend.Assets.ByPath[r.URL.Path]