Improve search result page

This commit is contained in:
2026-07-20 08:54:10 +02:00
parent d0325fdec5
commit 5303645173
5 changed files with 258 additions and 68 deletions
+20 -1
View File
@@ -121,7 +121,9 @@
// diary photo grids wrap each thumbnail in a .photo-grid anchor. // diary photo grids wrap each thumbnail in a .photo-grid anchor.
// The left tree rail's file anchors (a.tree-file) join in too; // The left tree rail's file anchors (a.tree-file) join in too;
// folder anchors there end in "/" and fall through to navigation. // folder anchors there end in "/" and fall through to navigation.
var anchor = e.target.closest('.list-item a, a.thumb-tile, .photo-grid a, aside.tree-sidebar a.tree-file'); // Search file-result anchors join in too: page results end in "/"
// and fall through to navigation, file results don't and open locally.
var anchor = e.target.closest('.list-item a, a.thumb-tile, .photo-grid a, aside.tree-sidebar a.tree-file, .search-card a');
if (!anchor) return; if (!anchor) return;
var item = anchor.closest('.list-item'); var item = anchor.closest('.list-item');
// Only intercept the primary file link, and only for files (not folders). // Only intercept the primary file link, and only for files (not folders).
@@ -153,6 +155,22 @@
}); });
} }
function wireFileRevealButtons() {
// Per-result "open location" buttons on the search page. Unlike the
// page reveal button (which reveals window.location), each carries its
// own file path so it reveals that specific file in its folder.
if (!state.available) return;
var btns = document.querySelectorAll('[data-companion-file-reveal]');
btns.forEach(function (btn) {
btn.hidden = false;
btn.addEventListener('click', function () {
var rel = btn.getAttribute('data-companion-file-reveal');
if (!rel) return;
companionGET('/open-folder', { path: rel }).catch(function () { });
});
});
}
function probeStatus() { function probeStatus() {
return companionGET('/status').then(function (r) { return companionGET('/status').then(function (r) {
if (!r.ok) throw new Error('status ' + r.status); if (!r.ok) throw new Error('status ' + r.status);
@@ -171,6 +189,7 @@
updateFooterIcon(); updateFooterIcon();
wireFileLinks(); wireFileLinks();
wireRevealButton(); wireRevealButton();
wireFileRevealButtons();
}); });
} }
+18 -4
View File
@@ -4,15 +4,29 @@
{{define "content"}} {{define "content"}}
{{if .Query}} {{if .Query}}
{{if .Results}} {{if or .Pages .Files}}
<p class="muted">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for &ldquo;{{.Query}}&rdquo;</p> {{if .Pages}}
<hr/> <h2 class="search-section">Matching Pages <span class="muted">{{.PageTotal}}</span></h2>
{{range .Results}} {{range .Pages}}
<article class="search-card"> <article class="search-card">
<a href="{{.URL}}">{{.Name}}</a> <a href="{{.URL}}">{{.Name}}</a>
<div class="muted">/{{.Path}}</div> <div class="muted">/{{.Path}}</div>
</article> </article>
{{end}} {{end}}
{{end}}
{{if .Files}}
<h2 class="search-section">Matching Files <span class="muted">{{.FileTotal}}</span></h2>
{{range .Files}}
<article class="search-card">
<div class="row">
{{fileIcon .Name}}
<a href="{{.URL}}">{{.Name}}</a>
<button class="btn btn-small" data-companion-file-reveal="{{.Path}}" hidden title="Open location in file manager">open</button>
</div>
<div class="muted">/{{.Path}} · {{.Meta}}</div>
</article>
{{end}}
{{end}}
{{else}} {{else}}
<p class="empty">No matches for &ldquo;{{.Query}}&rdquo;.</p> <p class="empty">No matches for &ldquo;{{.Query}}&rdquo;.</p>
{{end}} {{end}}
+4 -3
View File
@@ -448,14 +448,15 @@ body.edit-mode footer { max-width: none; }
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-1); gap: var(--space-1);
padding-bottom: var(--space-4);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
border-bottom: var(--border-dashed);
word-break: break-word; word-break: break-word;
} }
.search-card:last-child { border-bottom: none; }
.search-card a { color: var(--link); font-size: 1.1rem; } .search-card a { color: var(--link); font-size: 1.1rem; }
.search-card a:hover { color: var(--link-hover); } .search-card a:hover { color: var(--link-hover); }
.search-section {
padding-bottom: var(--space-2);
border-bottom: var(--border-dashed);
}
/* === Floating action button === /* === Floating action button ===
Standalone FAB buttons (the tree rail, the right rail) are mobile-only and Standalone FAB buttons (the tree rail, the right rail) are mobile-only and
+10 -3
View File
@@ -41,6 +41,7 @@ func hashAsset(name string) string {
// cache-bust its lazy bundle load the same way the edit template does. // cache-bust its lazy bundle load the same way the edit template does.
var tmplFuncs = template.FuncMap{ var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion }, "editorBundleVersion": func() string { return editorBundleVersion },
"fileIcon": fileIcon,
} }
var ( var (
@@ -133,13 +134,19 @@ func main() {
// so the first search after a cold start still returns correct results. // so the first search after a cold start still returns correct results.
go func() { go func() {
folderIndex.buildMu.Lock() folderIndex.buildMu.Lock()
entries := buildFolderIndex(root) folders, files := buildIndexes(root)
now := time.Now()
folderIndex.Lock() folderIndex.Lock()
folderIndex.entries = entries folderIndex.entries = folders
folderIndex.builtAt = time.Now() folderIndex.builtAt = now
folderIndex.Unlock() folderIndex.Unlock()
fileIndex.Lock()
fileIndex.entries = files
fileIndex.builtAt = now
fileIndex.Unlock()
folderIndex.buildMu.Unlock() folderIndex.buildMu.Unlock()
close(folderIndex.ready) close(folderIndex.ready)
close(fileIndex.ready)
}() }()
if *reindexInterval > 0 { if *reindexInterval > 0 {
+203 -54
View File
@@ -5,6 +5,7 @@ import (
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"net/url"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -13,31 +14,58 @@ import (
"unicode" "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
type searchResult struct { type searchResult struct {
Name string Name string
URL string URL string
Path string Path string
Score int Score int
// Meta is the formatted "size · date" line for file results; empty for
// page results.
Meta string
} }
type searchPageData struct { type searchPageData struct {
Title string Title string
EditMode bool EditMode bool
Query string Query string
Results []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 IndexBuiltAt time.Time
RenderMS int64 RenderMS int64
} }
// folderEntry is a single indexed directory: its forward-slash relative path // indexEntry is the shared scoreable core of both folder and file index
// plus pre-tokenized basename so the per-query scoring loop avoids redoing // entries: a forward-slash relative path plus its pre-tokenized basename so
// the lowercasing and tokenization on every keystroke. // the per-query scoring loop avoids redoing the lowercasing and tokenization
type folderEntry struct { // on every request.
type indexEntry struct {
Path string Path string
NameLower string NameLower string
NameTokens []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 // folderIndex holds the in-memory directory index used by search. Writers
// always replace the entries slice wholesale so a reader that snapshots the // always replace the entries slice wholesale so a reader that snapshots the
// header under RLock can score without holding the lock. // header under RLock can score without holding the lock.
@@ -49,15 +77,29 @@ var folderIndex struct {
ready chan struct{} 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() { func init() {
folderIndex.ready = make(chan struct{}) folderIndex.ready = make(chan struct{})
fileIndex.ready = make(chan struct{})
} }
// handleSearch renders the search results page for the query in // handleSearch renders the search results page for the query in
// r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present. // r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present.
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) { func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q")) query := strings.TrimSpace(r.URL.Query().Get("q"))
results, builtAt := searchWiki(query) pages, builtAt := searchWiki(query)
files := searchFiles(query)
title := "Search" title := "Search"
if query != "" { if query != "" {
@@ -66,7 +108,10 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
data := searchPageData{ data := searchPageData{
Title: title, Title: title,
Query: query, Query: query,
Results: results, Pages: capResults(pages),
Files: capResults(files),
PageTotal: len(pages),
FileTotal: len(files),
IndexBuiltAt: builtAt, IndexBuiltAt: builtAt,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -76,6 +121,55 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
} }
} }
// 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 // searchWiki scores the cached folder index against query. Blocks on the
// initial build so the very first request after startup serves correct // initial build so the very first request after startup serves correct
// results rather than an empty list. Returns the snapshot's builtAt so the // results rather than an empty list. Returns the snapshot's builtAt so the
@@ -90,43 +184,63 @@ func searchWiki(query string) ([]searchResult, time.Time) {
if query == "" { if query == "" {
return nil, builtAt return nil, builtAt
} }
qLower := strings.ToLower(query) scored := scoreEntries(entries, query, true, func(e folderEntry) indexEntry { return e })
qTokens := tokenize(qLower) results := make([]searchResult, 0, len(scored))
if len(qTokens) == 0 { for _, s := range scored {
return nil, builtAt
}
var results []searchResult
for _, e := range entries {
score := scoreName(e.NameLower, e.NameTokens, qLower, qTokens)
if score == 0 {
continue
}
results = append(results, searchResult{ results = append(results, searchResult{
Name: filepath.Base(e.Path), Name: filepath.Base(s.entry.Path),
URL: "/" + e.Path + "/", URL: "/" + s.entry.Path + "/",
Path: e.Path, Path: s.entry.Path,
Score: score, Score: s.score,
}) })
} }
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, builtAt 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 // scoreName ranks how well nameLower matches the query. Whole-name exact
// match dominates; otherwise score is the sum of each token's best match // 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. // against the words in the name. nameTokens is precomputed by the index. fuzzy
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string) int { // 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 { if nameLower == qLower {
return 1000 return 1000
} }
@@ -147,7 +261,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
if best < 20 { if best < 20 {
best = 20 best = 20
} }
case levenshtein(w, qt) <= 2: case fuzzy && levenshtein(w, qt) <= 2:
if best < 5 { if best < 5 {
best = 5 best = 5
} }
@@ -214,12 +328,15 @@ func (h *handler) handleReindex(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// buildFolderIndex walks root and returns a fresh slice of folder entries. // buildIndexes walks root once and returns fresh folder and file entries. The
// Hidden directories (`.git`, `.thumbs`, …) are pruned; the root itself is // single pass avoids a second full traversal on the ARMv7 NAS. Hidden
// excluded since it cannot be a search match. // directories (`.git`, `.thumbs`, …) are pruned and hidden files skipped; the
func buildFolderIndex(root string) []folderEntry { // root itself and every `index.md` (page content, not a browsable file) are
// excluded.
func buildIndexes(root string) ([]folderEntry, []fileEntry) {
walkRoot := resolveWalkRoot(root) walkRoot := resolveWalkRoot(root)
var entries []folderEntry var folders []folderEntry
var files []fileEntry
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error { _ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return nil return nil
@@ -227,46 +344,78 @@ func buildFolderIndex(root string) []folderEntry {
if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip { if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
return walkErr return walkErr
} }
if !d.IsDir() || fsPath == walkRoot {
return nil
}
rel, relErr := filepath.Rel(walkRoot, fsPath) rel, relErr := filepath.Rel(walkRoot, fsPath)
if relErr != nil { if relErr != nil {
return nil return nil
} }
entries = append(entries, newFolderEntry(filepath.ToSlash(rel))) 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 nil
}) })
return entries return folders, files
} }
// newFolderEntry builds a folderEntry from a forward-slash relative path, // newFolderEntry builds a folderEntry from a forward-slash relative path,
// computing the lowercased basename and its tokens once so search scoring // computing the lowercased basename and its tokens once so search scoring
// doesn't have to redo it per query. // doesn't have to redo it per query.
func newFolderEntry(relPath string) folderEntry { 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 name := relPath
if i := strings.LastIndex(relPath, "/"); i >= 0 { if i := strings.LastIndex(relPath, "/"); i >= 0 {
name = relPath[i+1:] name = relPath[i+1:]
} }
nameLower := strings.ToLower(name) nameLower := strings.ToLower(name)
return folderEntry{ return indexEntry{
Path: relPath, Path: relPath,
NameLower: nameLower, NameLower: nameLower,
NameTokens: tokenize(nameLower), NameTokens: tokenize(nameLower),
} }
} }
// rebuildFolderIndex walks root and replaces the index entries atomically. // rebuildFolderIndex walks root once and atomically replaces both the folder
// buildMu serializes overlapping rebuilds (manual + ticker + startup) so // and file indexes. buildMu serializes overlapping rebuilds (manual + ticker +
// the WalkDir cost is paid once even under contention. // startup) so the WalkDir cost is paid once even under contention.
func rebuildFolderIndex(root string) { func rebuildFolderIndex(root string) {
folderIndex.buildMu.Lock() folderIndex.buildMu.Lock()
defer folderIndex.buildMu.Unlock() defer folderIndex.buildMu.Unlock()
entries := buildFolderIndex(root) folders, files := buildIndexes(root)
now := time.Now()
folderIndex.Lock() folderIndex.Lock()
folderIndex.entries = entries folderIndex.entries = folders
folderIndex.builtAt = time.Now() folderIndex.builtAt = now
folderIndex.Unlock() 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. // folderIndexAdd appends relPath as a new entry. No-op for empty/root paths.