Improve search result page
This commit is contained in:
+20
-1
@@ -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
@@ -4,15 +4,29 @@
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Query}}
|
||||
{{if .Results}}
|
||||
<p class="muted">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”</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 “{{.Query}}”.</p>
|
||||
{{end}}
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user