Add search index
This commit is contained in:
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
@@ -18,30 +20,55 @@ type searchResult struct {
|
||||
}
|
||||
|
||||
type searchPageData struct {
|
||||
Title string
|
||||
Crumbs []crumb
|
||||
EditMode bool
|
||||
Query string
|
||||
Results []searchResult
|
||||
RenderMS int64
|
||||
Title string
|
||||
Crumbs []crumb
|
||||
EditMode bool
|
||||
Query string
|
||||
Results []searchResult
|
||||
IndexBuiltAt time.Time
|
||||
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.
|
||||
// folderEntry is a single indexed directory: its forward-slash relative path
|
||||
// plus pre-tokenized basename so the per-query scoring loop avoids redoing
|
||||
// the lowercasing and tokenization on every keystroke.
|
||||
type folderEntry struct {
|
||||
Path string
|
||||
NameLower string
|
||||
NameTokens []string
|
||||
}
|
||||
|
||||
// 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{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
folderIndex.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"))
|
||||
results := searchWiki(h.root, query)
|
||||
results, builtAt := searchWiki(query)
|
||||
|
||||
title := "Search"
|
||||
if query != "" {
|
||||
title = "Search: " + query
|
||||
}
|
||||
data := searchPageData{
|
||||
Title: title,
|
||||
Crumbs: []crumb{{Name: "search", URL: "/?q=" + query}},
|
||||
Query: query,
|
||||
Results: results,
|
||||
Title: title,
|
||||
Crumbs: []crumb{{Name: "search", URL: "/?q=" + query}},
|
||||
Query: query,
|
||||
Results: results,
|
||||
IndexBuiltAt: builtAt,
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
data.RenderMS = elapsedMS(r)
|
||||
@@ -50,48 +77,39 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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
|
||||
return nil, builtAt
|
||||
}
|
||||
qLower := strings.ToLower(query)
|
||||
qTokens := tokenize(qLower)
|
||||
if len(qTokens) == 0 {
|
||||
return nil
|
||||
return nil, builtAt
|
||||
}
|
||||
|
||||
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)
|
||||
for _, e := range entries {
|
||||
score := scoreName(e.NameLower, e.NameTokens, qLower, qTokens)
|
||||
if score == 0 {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(walkRoot, fsPath)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
continue
|
||||
}
|
||||
results = append(results, searchResult{
|
||||
Name: name,
|
||||
URL: "/" + filepath.ToSlash(rel) + "/",
|
||||
Path: filepath.ToSlash(rel),
|
||||
Name: filepath.Base(e.Path),
|
||||
URL: "/" + e.Path + "/",
|
||||
Path: e.Path,
|
||||
Score: score,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(results, func(i, j int) bool {
|
||||
if results[i].Score != results[j].Score {
|
||||
@@ -103,22 +121,20 @@ func searchWiki(root, query string) []searchResult {
|
||||
}
|
||||
return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
|
||||
})
|
||||
return results
|
||||
return results, builtAt
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// against the words in the name. nameTokens is precomputed by the index.
|
||||
func scoreName(nameLower string, nameTokens []string, 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 {
|
||||
for _, w := range nameTokens {
|
||||
switch {
|
||||
case w == qt:
|
||||
if best < 100 {
|
||||
@@ -143,6 +159,140 @@ func scoreName(nameLower, qLower string, qTokens []string) int {
|
||||
return score
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// buildFolderIndex walks root and returns a fresh slice of folder entries.
|
||||
// Hidden directories (`.git`, `.thumbs`, …) are pruned; the root itself is
|
||||
// excluded since it cannot be a search match.
|
||||
func buildFolderIndex(root string) []folderEntry {
|
||||
walkRoot := resolveWalkRoot(root)
|
||||
var entries []folderEntry
|
||||
_ = 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
|
||||
}
|
||||
rel, relErr := filepath.Rel(walkRoot, fsPath)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
entries = append(entries, newFolderEntry(filepath.ToSlash(rel)))
|
||||
return nil
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
// 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 {
|
||||
name := relPath
|
||||
if i := strings.LastIndex(relPath, "/"); i >= 0 {
|
||||
name = relPath[i+1:]
|
||||
}
|
||||
nameLower := strings.ToLower(name)
|
||||
return folderEntry{
|
||||
Path: relPath,
|
||||
NameLower: nameLower,
|
||||
NameTokens: tokenize(nameLower),
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildFolderIndex walks root and replaces the index entries atomically.
|
||||
// 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()
|
||||
entries := buildFolderIndex(root)
|
||||
folderIndex.Lock()
|
||||
folderIndex.entries = entries
|
||||
folderIndex.builtAt = time.Now()
|
||||
folderIndex.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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user