Search phase 1
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var searchTmpl = template.Must(template.New("search.html").ParseFS(assets, "assets/search.html"))
|
||||
|
||||
// Match ranks. Lower is better.
|
||||
const (
|
||||
rankExact = 0
|
||||
rankPrefix = 1
|
||||
rankSubstring = 2
|
||||
rankFuzzy = 3
|
||||
)
|
||||
|
||||
type searchResult struct {
|
||||
Name string
|
||||
URL string
|
||||
Path string
|
||||
Rank int
|
||||
}
|
||||
|
||||
type searchPageData struct {
|
||||
Query string
|
||||
Results []searchResult
|
||||
}
|
||||
|
||||
// handleSearch walks the wiki root and renders a search results page for the
|
||||
// query in r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is
|
||||
// present.
|
||||
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
results := searchFolders(h.root, query)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := searchTmpl.Execute(w, searchPageData{Query: query, Results: results}); err != nil {
|
||||
log.Printf("search template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// searchFolders walks root and returns directories whose final path segment
|
||||
// matches the query, ranked best-first. Returns nil for an empty query.
|
||||
func searchFolders(root, query string) []searchResult {
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
q := strings.ToLower(query)
|
||||
maxDist := 2
|
||||
if len([]rune(q)) > 6 {
|
||||
maxDist = 3
|
||||
}
|
||||
|
||||
var results []searchResult
|
||||
_ = filepath.WalkDir(root, func(fsPath string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
if d.IsDir() && fsPath != root {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.IsDir() || fsPath == root {
|
||||
return nil
|
||||
}
|
||||
rank, ok := matchRank(strings.ToLower(name), q, maxDist)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(root, fsPath)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
urlPath := "/" + filepath.ToSlash(rel) + "/"
|
||||
results = append(results, searchResult{
|
||||
Name: name,
|
||||
URL: urlPath,
|
||||
Path: filepath.ToSlash(rel),
|
||||
Rank: rank,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
sort.SliceStable(results, func(i, j int) bool {
|
||||
if results[i].Rank != results[j].Rank {
|
||||
return results[i].Rank < results[j].Rank
|
||||
}
|
||||
return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
// matchRank returns the best (lowest) rank for which name matches q, or
|
||||
// (0, false) if no rule matches. Inputs are expected to be lowercased.
|
||||
func matchRank(name, q string, maxDist int) (int, bool) {
|
||||
if name == q {
|
||||
return rankExact, true
|
||||
}
|
||||
if strings.HasPrefix(name, q) {
|
||||
return rankPrefix, true
|
||||
}
|
||||
if strings.Contains(name, q) {
|
||||
return rankSubstring, true
|
||||
}
|
||||
if levenshtein(name, q) <= maxDist {
|
||||
return rankFuzzy, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// levenshtein returns the edit distance between a and b. Operates on runes so
|
||||
// multi-byte characters count as one edit.
|
||||
func levenshtein(a, b string) int {
|
||||
ar, br := []rune(a), []rune(b)
|
||||
if len(ar) == 0 {
|
||||
return len(br)
|
||||
}
|
||||
if len(br) == 0 {
|
||||
return len(ar)
|
||||
}
|
||||
prev := make([]int, len(br)+1)
|
||||
curr := make([]int, len(br)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ar); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(br); j++ {
|
||||
cost := 1
|
||||
if ar[i-1] == br[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
del := prev[j] + 1
|
||||
ins := curr[j-1] + 1
|
||||
sub := prev[j-1] + cost
|
||||
curr[j] = min3(del, ins, sub)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(br)]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
m := a
|
||||
if b < m {
|
||||
m = b
|
||||
}
|
||||
if c < m {
|
||||
m = c
|
||||
}
|
||||
return m
|
||||
}
|
||||
Reference in New Issue
Block a user