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.
// The left tree rail's file anchors (a.tree-file) join in too;
// 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;
var item = anchor.closest('.list-item');
// 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() {
return companionGET('/status').then(function (r) {
if (!r.ok) throw new Error('status ' + r.status);
@@ -171,6 +189,7 @@
updateFooterIcon();
wireFileLinks();
wireRevealButton();
wireFileRevealButtons();
});
}
+18 -4
View File
@@ -4,15 +4,29 @@
{{define "content"}}
{{if .Query}}
{{if .Results}}
<p class="muted">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for &ldquo;{{.Query}}&rdquo;</p>
<hr/>
{{range .Results}}
{{if or .Pages .Files}}
{{if .Pages}}
<h2 class="search-section">Matching Pages <span class="muted">{{.PageTotal}}</span></h2>
{{range .Pages}}
<article class="search-card">
<a href="{{.URL}}">{{.Name}}</a>
<div class="muted">/{{.Path}}</div>
</article>
{{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}}
<p class="empty">No matches for &ldquo;{{.Query}}&rdquo;.</p>
{{end}}
+4 -3
View File
@@ -448,14 +448,15 @@ body.edit-mode footer { max-width: none; }
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-bottom: var(--space-4);
margin-bottom: var(--space-4);
border-bottom: var(--border-dashed);
word-break: break-word;
}
.search-card:last-child { border-bottom: none; }
.search-card a { color: var(--link); font-size: 1.1rem; }
.search-card a:hover { color: var(--link-hover); }
.search-section {
padding-bottom: var(--space-2);
border-bottom: var(--border-dashed);
}
/* === Floating action button ===
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.
var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion },
"fileIcon": fileIcon,
}
var (
@@ -133,13 +134,19 @@ func main() {
// so the first search after a cold start still returns correct results.
go func() {
folderIndex.buildMu.Lock()
entries := buildFolderIndex(root)
folders, files := buildIndexes(root)
now := time.Now()
folderIndex.Lock()
folderIndex.entries = entries
folderIndex.builtAt = time.Now()
folderIndex.entries = folders
folderIndex.builtAt = now
folderIndex.Unlock()
fileIndex.Lock()
fileIndex.entries = files
fileIndex.builtAt = now
fileIndex.Unlock()
folderIndex.buildMu.Unlock()
close(folderIndex.ready)
close(fileIndex.ready)
}()
if *reindexInterval > 0 {
+206 -57
View File
@@ -5,6 +5,7 @@ import (
"io/fs"
"log"
"net/http"
"net/url"
"path/filepath"
"sort"
"strings"
@@ -13,31 +14,58 @@ import (
"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 {
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
Results []searchResult
Title string
EditMode bool
Query string
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
}
// 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 {
// 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.
@@ -49,15 +77,29 @@ var folderIndex 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() {
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"))
results, builtAt := searchWiki(query)
pages, builtAt := searchWiki(query)
files := searchFiles(query)
title := "Search"
if query != "" {
@@ -66,7 +108,10 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
data := searchPageData{
Title: title,
Query: query,
Results: results,
Pages: capResults(pages),
Files: capResults(files),
PageTotal: len(pages),
FileTotal: len(files),
IndexBuiltAt: builtAt,
}
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
// initial build so the very first request after startup serves correct
// 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 == "" {
return nil, builtAt
}
qLower := strings.ToLower(query)
qTokens := tokenize(qLower)
if len(qTokens) == 0 {
return nil, builtAt
}
var results []searchResult
for _, e := range entries {
score := scoreName(e.NameLower, e.NameTokens, qLower, qTokens)
if score == 0 {
continue
}
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(e.Path),
URL: "/" + e.Path + "/",
Path: e.Path,
Score: score,
Name: filepath.Base(s.entry.Path),
URL: "/" + s.entry.Path + "/",
Path: s.entry.Path,
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
}
// 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.
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string) int {
// 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 1000
}
@@ -147,7 +261,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
if best < 20 {
best = 20
}
case levenshtein(w, qt) <= 2:
case fuzzy && levenshtein(w, qt) <= 2:
if best < 5 {
best = 5
}
@@ -214,12 +328,15 @@ func (h *handler) handleReindex(w http.ResponseWriter, r *http.Request) {
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 {
// 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 entries []folderEntry
var folders []folderEntry
var files []fileEntry
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
if err != nil {
return nil
@@ -227,46 +344,78 @@ func buildFolderIndex(root string) []folderEntry {
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)))
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 entries
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 folderEntry{
return indexEntry{
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.
// 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()
entries := buildFolderIndex(root)
folders, files := buildIndexes(root)
now := time.Now()
folderIndex.Lock()
folderIndex.entries = entries
folderIndex.builtAt = time.Now()
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.