diff --git a/assets/page.html b/assets/page.html index 800ac6a..214796e 100644 --- a/assets/page.html +++ b/assets/page.html @@ -21,6 +21,11 @@ /{{.Name}} {{end}} + {{if not .EditMode}} +
+ +
+ {{end}} {{if .EditMode}} CANCEL diff --git a/assets/search.html b/assets/search.html new file mode 100644 index 0000000..17da7ed --- /dev/null +++ b/assets/search.html @@ -0,0 +1,43 @@ + + + + + + Search{{if .Query}}: {{.Query}}{{end}} + + + + + + +
+ +
+ + +
+
+
+ {{if .Query}} + {{if .Results}} +
+
{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”
+ {{range .Results}} +
+ {{.Name}} + {{.Path}} +
+ {{end}} +
+ {{else}} +

No folders match “{{.Query}}”.

+ {{end}} + {{else}} +

Enter a query above.

+ {{end}} +
+ + diff --git a/assets/style.css b/assets/style.css index fd78257..e3f1dbd 100644 --- a/assets/style.css +++ b/assets/style.css @@ -359,6 +359,27 @@ textarea { 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 { color: var(--text-muted); diff --git a/main.go b/main.go index a30a81d..93e99d1 100644 --- a/main.go +++ b/main.go @@ -115,6 +115,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if r.Method == http.MethodGet && urlPath == "/" && r.URL.Query().Has("q") { + h.handleSearch(w, r) + return + } + info, err := os.Stat(fsPath) if err != nil { if os.IsNotExist(err) { diff --git a/search.go b/search.go new file mode 100644 index 0000000..7db253a --- /dev/null +++ b/search.go @@ -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 +}