package main import ( "io/fs" "log" "net/http" "path/filepath" "sort" "strings" "unicode" ) type searchResult struct { Name string URL string Path string Score int } type searchPageData struct { Title string Crumbs []crumb EditMode bool Query string Results []searchResult RenderMS int64 } // 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 := searchWiki(h.root, query) title := "Search" if query != "" { title = "Search: " + query } data := searchPageData{ Title: title, Crumbs: []crumb{{Name: "search", URL: "/?q=" + query}}, Query: query, Results: results, } w.Header().Set("Content-Type", "text/html; charset=utf-8") data.RenderMS = elapsedMS(r) if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil { log.Printf("search template error: %v", err) } } // searchWiki walks root and scores each directory by how well the folder name // matches the query. Page contents are not searched. Higher score = more // relevant; exact matches rank first. func searchWiki(root, query string) []searchResult { if query == "" { return nil } qLower := strings.ToLower(query) qTokens := tokenize(qLower) if len(qTokens) == 0 { return nil } walkRoot := resolveWalkRoot(root) var results []searchResult _ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error { if err != nil { return nil } if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip { return walkErr } if !d.IsDir() || fsPath == walkRoot { return nil } name := d.Name() score := scoreName(strings.ToLower(name), qLower, qTokens) if score == 0 { return nil } rel, relErr := filepath.Rel(walkRoot, fsPath) if relErr != nil { return nil } results = append(results, searchResult{ Name: name, URL: "/" + filepath.ToSlash(rel) + "/", Path: filepath.ToSlash(rel), Score: score, }) return nil }) sort.SliceStable(results, func(i, j int) bool { if results[i].Score != results[j].Score { return results[i].Score > results[j].Score } di, dj := strings.Count(results[i].Path, "/"), strings.Count(results[j].Path, "/") if di != dj { return di < dj } return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name) }) return results } // scoreName ranks how well nameLower matches the query. Whole-name exact // match dominates; otherwise score is the sum of each token's best match // against the words in the name. Position within the name does not matter — // nesting depth is the tiebreaker, applied by the caller. func scoreName(nameLower, qLower string, qTokens []string) int { if nameLower == qLower { return 1000 } score := 0 nameWords := tokenize(nameLower) for _, qt := range qTokens { best := 0 for _, w := range nameWords { switch { case w == qt: if best < 100 { best = 100 } case strings.HasPrefix(w, qt): if best < 50 { best = 50 } case strings.Contains(w, qt): if best < 20 { best = 20 } case levenshtein(w, qt) <= 2: if best < 5 { best = 5 } } } score += best } return score } // resolveWalkRoot resolves symlinks so WalkDir descends into the real tree // even when the configured wiki root is itself a symlink (as on the NAS). func resolveWalkRoot(root string) string { if r, err := filepath.EvalSymlinks(root); err == nil { return r } return root } // hiddenSkip handles dotfile/dot-dir entries during a WalkDir. It returns // (skipped, walkErr): skipped=true means the caller should `return walkErr` // to either prune the subtree (hidden dir) or move past the entry (hidden // file). When skipped=false the entry should be processed normally. func hiddenSkip(fsPath, walkRoot string, d fs.DirEntry) (bool, error) { if !strings.HasPrefix(d.Name(), ".") { return false, nil } if d.IsDir() && fsPath != walkRoot { return true, filepath.SkipDir } return true, nil } // tokenize splits s into lowercase word tokens, breaking on any rune that is // not a letter or digit. Unicode-aware so umlauts etc. survive intact. func tokenize(s string) []string { var tokens []string var b strings.Builder for _, r := range s { if unicode.IsLetter(r) || unicode.IsDigit(r) { b.WriteRune(unicode.ToLower(r)) continue } if b.Len() > 0 { tokens = append(tokens, b.String()) b.Reset() } } if b.Len() > 0 { tokens = append(tokens, b.String()) } return tokens } // 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 }