package main import ( "embed" "encoding/json" "html/template" "log" "net/http" "regexp" "sort" "strings" ) // Variables to customise the search behaviour const ( listenStr = "0.0.0.0:8080" titleScore = 20 tagsScore = 10 descriptionScore = 5 contentScore = 1 ) //go:embed index.html search.html var templatesFS embed.FS //go:embed index.json var indexFS embed.FS // html templates var searchTemplate = template.Must(template.New("search").ParseFS(templatesFS, "search.html", "index.html")) // index records type JsonIndexRecord struct { Content string `json:"content"` Description string `json:"description"` Permalink string `json:"permalink"` Tags []string `json:"tags"` Title string `json:"title"` } type SearchIndexRecord struct { Title []string Tags []string Description []string Content []string Permalink string } var jsonIndex []JsonIndexRecord var searchIndex []SearchIndexRecord // The following works on index entries to clean up words : remove case, punctuation, words less than 3 characters var validWord = regexp.MustCompile(`([a-zA-Z0-9]+)`) func normalizeWords(words []string) (result []string) { sort.Strings(words) // to easily remove duplicates lastword := "" for i := 0; i < len(words); i++ { word := strings.ToLower(validWord.FindString(words[i])) // Get rid of punctuation, would not work well for french apostrophes if word == lastword || len(word) < 3 { // we remove duplicates and words less than 3 characters continue } result = append(result, word) lastword = word } return } // The scoring function used by the index func scoreIndex(words []string, indexWords []string) (score int) { for i := 0; i < len(indexWords); i++ { for j := 0; j < len(words); j++ { if strings.Contains(indexWords[i], words[j]) { score++ } } } return } // We need a way to sort by score and get an article Id type Pair struct { Id int Score int } type Pairs []Pair func (p Pairs) Len() int { return len(p) } func (p Pairs) Swap(i, j int) { p[i], p[j] = p[j], p[i] } func (p Pairs) Less(i, j int) bool { return p[i].Score < p[j].Score } // the template variables type SearchPage struct { Query string SearchTitle bool SearchTags bool SearchDescription bool SearchContent bool Results []JsonIndexRecord } // The search handler of the webui func searchHandler(w http.ResponseWriter, r *http.Request) error { p := SearchPage{ Query: r.FormValue("query"), } if p.Query != "" && len(p.Query) >= 3 && len(p.Query) <= 64 { log.Printf("searching for: %s", p.Query) // First we reset the search options status p.SearchTitle = r.FormValue("searchTitle") == "true" p.SearchTags = r.FormValue("searchTags") == "true" p.SearchDescription = r.FormValue("searchDescription") == "true" p.SearchContent = r.FormValue("searchContent") == "true" // Then we walk the index words := normalizeWords(strings.Fields(strings.ToLower(p.Query))) scores := make(Pairs, 0) for i := 0; i < len(jsonIndex); i++ { score := 0 if p.SearchTitle { score = titleScore * scoreIndex(words, searchIndex[i].Title) } if p.SearchTags { score += tagsScore * scoreIndex(words, searchIndex[i].Tags) } if p.SearchDescription { score += descriptionScore * scoreIndex(words, searchIndex[i].Description) } if p.SearchContent { score += contentScore * scoreIndex(words, searchIndex[i].Content) } if score > 0 { scores = append(scores, Pair{i, score}) } } // we sort highest scores first sort.Sort(scores) for i := len(scores) - 1; i >= 0; i-- { p.Results = append(p.Results, jsonIndex[scores[i].Id]) } } else { // default checkbox values p.SearchTitle = true p.SearchTags = true } w.Header().Set("Cache-Control", "no-store, no-cache") if err := searchTemplate.ExecuteTemplate(w, "index.html", p); err != nil { return newStatusError(http.StatusInternalServerError, err) } return nil } // the environment that will be passed to our handlers type handlerError interface { error Status() int } type statusError struct { code int err error } func (e *statusError) Error() string { return e.err.Error() } func (e *statusError) Status() int { return e.code } func newStatusError(code int, err error) error { return &statusError{code: code, err: err} } type handler struct { h func(w http.ResponseWriter, r *http.Request) error } // ServeHTTP allows our handler type to satisfy http.Handler func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path err := h.h(w, r) if err != nil { switch e := err.(type) { case handlerError: log.Printf("HTTP %d - %s", e.Status(), e) http.Error(w, e.Error(), e.Status()) default: // Any error types we don't specifically look out for default to serving a HTTP 500 log.Printf("%s : handler returned an unexpected error : %+v", path, e) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } } // The main function func main() { if indexFile, err := indexFS.Open("index.json"); err != nil { log.Fatal("Failed to open index.json : " + err.Error()) } else { defer indexFile.Close() // we decode the jsonIndex if err := json.NewDecoder(indexFile).Decode(&jsonIndex); err != nil { log.Fatal("Failed to decode index.json : " + err.Error()) } // then build the search index with normalized words searchIndex = make([]SearchIndexRecord, len(jsonIndex)) for i := 0; i < len(jsonIndex); i++ { searchIndex[i].Title = normalizeWords(strings.Fields(jsonIndex[i].Title)) searchIndex[i].Description = normalizeWords(strings.Fields(jsonIndex[i].Description)) searchIndex[i].Tags = normalizeWords(jsonIndex[i].Tags) searchIndex[i].Content = normalizeWords(strings.Fields(jsonIndex[i].Content)) searchIndex[i].Permalink = jsonIndex[i].Permalink } } http.Handle("/", handler{searchHandler}) log.Printf("Starting webui on %s", listenStr) log.Fatal(http.ListenAndServe(listenStr, nil)) }