package main import ( "encoding/json" "io/fs" "log" "net/http" "net/url" "path/filepath" "sort" "strings" "sync" "time" "unicode" ) // searchSectionCap bounds how many rows each results section renders. The // section header still reports the true total so the user knows more exist. const searchSectionCap = 10 // exactNameScore is the score scoreName assigns to a whole-name exact match. // handleSearch peels these into the promoted "Exact Match" section. const exactNameScore = 1000 type searchResult struct { Name string URL string Path string Score int // Meta is the formatted "size · date" line for file results; empty for // page results. Meta string } type searchPageData struct { Title string EditMode bool Query string // Exact holds page(s) whose name equals the query exactly, promoted to // their own section above the fuzzy matches. Exact []searchResult Pages []searchResult Files []searchResult // PageTotal/FileTotal are the true match totals; Pages/Files are capped at // searchSectionCap for rendering. PageTotal int FileTotal int IndexBuiltAt time.Time RenderMS int64 } // indexEntry is the shared scoreable core of both folder and file index // entries: a forward-slash relative path plus its pre-tokenized basename so // the per-query scoring loop avoids redoing the lowercasing and tokenization // on every request. type indexEntry struct { Path string NameLower string NameTokens []string } // folderEntry is a single indexed directory. It is exactly an indexEntry; the // alias keeps the existing folder-index code readable while letting files and // folders share the scoring loop. type folderEntry = indexEntry // fileEntry is a single indexed file: its scoreable core plus the size/modtime // captured during the walk so results can show listing-parity metadata without // a second stat. type fileEntry struct { indexEntry Size int64 ModTime time.Time } // folderIndex holds the in-memory directory index used by search. Writers // always replace the entries slice wholesale so a reader that snapshots the // header under RLock can score without holding the lock. var folderIndex struct { sync.RWMutex entries []folderEntry builtAt time.Time buildMu sync.Mutex ready chan struct{} } // fileIndex mirrors folderIndex for files. It is held separately so file // volume can't perturb the page index, and shares folderIndex.buildMu since // both are populated by the same single-pass walk. It is refreshed only by the // full rebuild (startup / ticker / manual), never by the incremental folder // hooks — file freshness on disk lags until the next rebuild. var fileIndex struct { sync.RWMutex entries []fileEntry builtAt time.Time ready chan struct{} } func init() { folderIndex.ready = make(chan struct{}) fileIndex.ready = make(chan struct{}) } // handleSearch renders the 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")) pages, builtAt := searchWiki(query) files := searchFiles(query) // searchWiki sorts by score desc, so exact-name matches (score 1000) are // at the front; peel them into their own section and drop them from the // fuzzy page list so they aren't shown twice. var exact []searchResult for len(pages) > 0 && pages[0].Score == exactNameScore { exact = append(exact, pages[0]) pages = pages[1:] } title := "Search" if query != "" { title = "Search: " + query } data := searchPageData{ Title: title, Query: query, Exact: exact, Pages: capResults(pages), Files: capResults(files), PageTotal: len(pages), FileTotal: len(files), IndexBuiltAt: builtAt, } 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) } } // capResults truncates a section to searchSectionCap rows for rendering. func capResults(results []searchResult) []searchResult { if len(results) > searchSectionCap { return results[:searchSectionCap] } return results } // scoredEntry pairs a matched index entry with its score before it is turned // into a UI searchResult. type scoredEntry[T any] struct { entry T score int } // scoreEntries scores every entry against query, drops non-matches, and returns // the survivors sorted by score (desc), then path depth (asc), then basename // (asc). core extracts the shared scoreable fields so the folder and file // indexes reuse one loop. fuzzy toggles the levenshtein fallback (pages yes, // files no). Returns nil for an empty/tokenless query. func scoreEntries[T any](entries []T, query string, fuzzy bool, core func(T) indexEntry) []scoredEntry[T] { qLower := strings.ToLower(query) qTokens := tokenize(qLower) if len(qTokens) == 0 { return nil } var out []scoredEntry[T] for _, e := range entries { c := core(e) score := scoreName(c.NameLower, c.NameTokens, qLower, qTokens, fuzzy) if score == 0 { continue } out = append(out, scoredEntry[T]{entry: e, score: score}) } sort.SliceStable(out, func(i, j int) bool { if out[i].score != out[j].score { return out[i].score > out[j].score } ci, cj := core(out[i].entry), core(out[j].entry) di, dj := strings.Count(ci.Path, "/"), strings.Count(cj.Path, "/") if di != dj { return di < dj } return ci.NameLower < cj.NameLower }) return out } // searchWiki scores the cached folder index against query. Blocks on the // initial build so the very first request after startup serves correct // results rather than an empty list. Returns the snapshot's builtAt so the // UI can show how fresh the index is. func searchWiki(query string) ([]searchResult, time.Time) { <-folderIndex.ready folderIndex.RLock() entries := folderIndex.entries builtAt := folderIndex.builtAt folderIndex.RUnlock() if query == "" { return nil, builtAt } scored := scoreEntries(entries, query, true, func(e folderEntry) indexEntry { return e }) results := make([]searchResult, 0, len(scored)) for _, s := range scored { results = append(results, searchResult{ Name: filepath.Base(s.entry.Path), URL: "/" + s.entry.Path + "/", Path: s.entry.Path, Score: s.score, }) } return results, builtAt } // searchFiles scores the cached file index against query, matching on filename // only. Blocks on the initial build so the first request after startup doesn't // serve an empty Files section while the walk is still running. func searchFiles(query string) []searchResult { <-fileIndex.ready fileIndex.RLock() entries := fileIndex.entries fileIndex.RUnlock() if query == "" { return nil } scored := scoreEntries(entries, query, false, func(e fileEntry) indexEntry { return e.indexEntry }) results := make([]searchResult, 0, len(scored)) for _, s := range scored { p := s.entry.Path results = append(results, searchResult{ Name: filepath.Base(p), URL: fileURL(p), Path: p, Score: s.score, Meta: formatSize(s.entry.Size) + " · " + s.entry.ModTime.Format("2006-01-02"), }) } return results } // fileURL builds the browser URL for a file's forward-slash relative path, // percent-escaping each segment so spaces/umlauts/&c. survive round-tripping // through the companion's wikiPathFromHref decode. func fileURL(relPath string) string { parts := strings.Split(relPath, "/") for i, p := range parts { parts[i] = url.PathEscape(p) } return "/" + strings.Join(parts, "/") } // 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. nameTokens is precomputed by the index. fuzzy // enables the levenshtein near-match fallback; the file index passes false so // large file volumes don't pay the edit-distance cost per query. func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string, fuzzy bool) int { if nameLower == qLower { return exactNameScore } score := 0 for _, qt := range qTokens { best := 0 for _, w := range nameTokens { 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 fuzzy && levenshtein(w, qt) <= 2: if best < 5 { best = 5 } } } score += best } return score } // handleSearchSuggest serves the JSON typeahead for the header dropdown and // the editor's link picker. Caps results at 5; reports total so the UI can // surface a "show all" footer when more matches exist. Empty/whitespace query // is a no-op (200 with empty results), not a 400 — every keystroke fires this. func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) { if !h.checkAuth(w, r) { return } query := strings.TrimSpace(r.URL.Query().Get("q")) type suggestResult struct { Name string `json:"name"` Path string `json:"path"` URL string `json:"url"` } type suggestResp struct { Query string `json:"query"` Results []suggestResult `json:"results"` Total int `json:"total"` } resp := suggestResp{Query: query, Results: []suggestResult{}} if query != "" { all, _ := searchWiki(query) resp.Total = len(all) limit := 5 if len(all) < limit { limit = len(all) } for i := 0; i < limit; i++ { resp.Results = append(resp.Results, suggestResult{ Name: all[i].Name, Path: all[i].Path, URL: all[i].URL, }) } } w.Header().Set("Content-Type", "application/json; charset=utf-8") if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("search suggest encode error: %v", err) } } // handleReindex rebuilds the folder index synchronously and returns 204. // The frontend reloads the page on success. Serialized via buildMu so a // double-click waits rather than running two walks in parallel. func (h *handler) handleReindex(w http.ResponseWriter, r *http.Request) { if !h.checkAuth(w, r) { return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } rebuildFolderIndex(h.root) w.WriteHeader(http.StatusNoContent) } // buildIndexes walks root once and returns fresh folder and file entries. The // single pass avoids a second full traversal on the ARMv7 NAS. Hidden // directories (`.git`, `.thumbs`, …) are pruned and hidden files skipped; the // root itself and every `index.md` (page content, not a browsable file) are // excluded. func buildIndexes(root string) ([]folderEntry, []fileEntry) { walkRoot := resolveWalkRoot(root) var folders []folderEntry var files []fileEntry _ = 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 } rel, relErr := filepath.Rel(walkRoot, fsPath) if relErr != nil { return nil } relSlash := filepath.ToSlash(rel) if d.IsDir() { if fsPath != walkRoot { folders = append(folders, newFolderEntry(relSlash)) } return nil } if d.Name() == "index.md" { return nil } info, infoErr := d.Info() if infoErr != nil { return nil } files = append(files, newFileEntry(relSlash, info.Size(), info.ModTime())) return nil }) return folders, files } // newFolderEntry builds a folderEntry from a forward-slash relative path, // computing the lowercased basename and its tokens once so search scoring // doesn't have to redo it per query. func newFolderEntry(relPath string) folderEntry { return newIndexEntry(relPath) } // newFileEntry builds a fileEntry, capturing the walk-time size/modtime so // results show listing-parity metadata without a second stat. func newFileEntry(relPath string, size int64, modTime time.Time) fileEntry { return fileEntry{ indexEntry: newIndexEntry(relPath), Size: size, ModTime: modTime, } } // newIndexEntry precomputes the lowercased basename and its tokens for the // per-query scoring loop. func newIndexEntry(relPath string) indexEntry { name := relPath if i := strings.LastIndex(relPath, "/"); i >= 0 { name = relPath[i+1:] } nameLower := strings.ToLower(name) return indexEntry{ Path: relPath, NameLower: nameLower, NameTokens: tokenize(nameLower), } } // rebuildFolderIndex walks root once and atomically replaces both the folder // and file indexes. buildMu serializes overlapping rebuilds (manual + ticker + // startup) so the WalkDir cost is paid once even under contention. func rebuildFolderIndex(root string) { folderIndex.buildMu.Lock() defer folderIndex.buildMu.Unlock() folders, files := buildIndexes(root) now := time.Now() folderIndex.Lock() folderIndex.entries = folders folderIndex.builtAt = now folderIndex.Unlock() fileIndex.Lock() fileIndex.entries = files fileIndex.builtAt = now fileIndex.Unlock() } // folderIndexAdd appends relPath as a new entry. No-op for empty/root paths. func folderIndexAdd(relPath string) { relPath = strings.Trim(relPath, "/") if relPath == "" { return } folderIndex.Lock() folderIndex.entries = append(folderIndex.entries, newFolderEntry(relPath)) folderIndex.Unlock() } // folderIndexRemoveSubtree drops the entry at relPath plus every descendant. // Replaces the slice rather than mutating in place so any in-flight search // reader keeps a valid snapshot. func folderIndexRemoveSubtree(relPath string) { relPath = strings.Trim(relPath, "/") if relPath == "" { return } prefix := relPath + "/" folderIndex.Lock() defer folderIndex.Unlock() old := folderIndex.entries out := make([]folderEntry, 0, len(old)) for _, e := range old { if e.Path == relPath || strings.HasPrefix(e.Path, prefix) { continue } out = append(out, e) } folderIndex.entries = out } // folderIndexRenameSubtree rewrites the path prefix for every entry under // oldRel. The renamed root entry's basename may have changed so its // NameLower/NameTokens are recomputed; descendants keep their basenames. func folderIndexRenameSubtree(oldRel, newRel string) { oldRel = strings.Trim(oldRel, "/") newRel = strings.Trim(newRel, "/") if oldRel == "" || newRel == "" { return } oldPrefix := oldRel + "/" folderIndex.Lock() defer folderIndex.Unlock() old := folderIndex.entries out := make([]folderEntry, len(old)) for i, e := range old { switch { case e.Path == oldRel: out[i] = newFolderEntry(newRel) case strings.HasPrefix(e.Path, oldPrefix): out[i] = folderEntry{ Path: newRel + "/" + strings.TrimPrefix(e.Path, oldPrefix), NameLower: e.NameLower, NameTokens: e.NameTokens, } default: out[i] = e } } folderIndex.entries = out } // folderIndexMergeSubtree updates the index after a merge move: it drops the // source root entry (that folder is gone) and rewrites every descendant's // prefix to live under newRel. Unlike folderIndexRenameSubtree it does not add // a newRel entry, since the destination folder already exists in the index. func folderIndexMergeSubtree(oldRel, newRel string) { oldRel = strings.Trim(oldRel, "/") newRel = strings.Trim(newRel, "/") if oldRel == "" || newRel == "" { return } oldPrefix := oldRel + "/" folderIndex.Lock() defer folderIndex.Unlock() old := folderIndex.entries out := make([]folderEntry, 0, len(old)) for _, e := range old { switch { case e.Path == oldRel: continue case strings.HasPrefix(e.Path, oldPrefix): out = append(out, folderEntry{ Path: newRel + "/" + strings.TrimPrefix(e.Path, oldPrefix), NameLower: e.NameLower, NameTokens: e.NameTokens, }) default: out = append(out, e) } } folderIndex.entries = out } // 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 }