Search phase 1
This commit is contained in:
@@ -21,6 +21,11 @@
|
|||||||
<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>
|
<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
|
{{if not .EditMode}}
|
||||||
|
<form class="search-form" action="/" method="get">
|
||||||
|
<input class="search-input" type="search" name="q" placeholder="Search…" />
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
{{if .EditMode}}
|
{{if .EditMode}}
|
||||||
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
||||||
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Search{{if .Query}}: {{.Query}}{{end}}</title>
|
||||||
|
<link rel="icon" href="/_/favicon.ico" />
|
||||||
|
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="stylesheet" href="/_/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
|
||||||
|
<span class="sep">/</span><span>search</span>
|
||||||
|
</nav>
|
||||||
|
<form class="search-form" action="/" method="get">
|
||||||
|
<input class="search-input" type="search" name="q" value="{{.Query}}" placeholder="Search folders…" autofocus />
|
||||||
|
<button class="btn" type="submit">GO</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{{if .Query}}
|
||||||
|
{{if .Results}}
|
||||||
|
<div class="listing">
|
||||||
|
<div class="listing-header">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”</div>
|
||||||
|
{{range .Results}}
|
||||||
|
<div class="listing-item">
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
<span class="meta">{{.Path}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">No folders match “{{.Query}}”.</p>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">Enter a query above.</p>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -359,6 +359,27 @@ textarea {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Search === */
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--secondary);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
width: 12rem;
|
||||||
|
max-width: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* === Muted text === */
|
/* === Muted text === */
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet && urlPath == "/" && r.URL.Query().Has("q") {
|
||||||
|
h.handleSearch(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
info, err := os.Stat(fsPath)
|
info, err := os.Stat(fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
|||||||
@@ -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