diff --git a/assets/companion.js b/assets/companion.js index d1f383e..f8df2d8 100644 --- a/assets/companion.js +++ b/assets/companion.js @@ -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(); }); } diff --git a/assets/search/main.html b/assets/search/main.html index e56f14c..ab5d81b 100644 --- a/assets/search/main.html +++ b/assets/search/main.html @@ -4,15 +4,29 @@ {{define "content"}} {{if .Query}} - {{if .Results}} -
{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”
-No matches for “{{.Query}}”.
{{end}} diff --git a/assets/style.css b/assets/style.css index f603ed7..4482a74 100644 --- a/assets/style.css +++ b/assets/style.css @@ -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 diff --git a/main.go b/main.go index c2f4d3a..442c373 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/search.go b/search.go index bc074c1..20d8cab 100644 --- a/search.go +++ b/search.go @@ -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.