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.
|
// 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
@@ -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 “{{.Query}}”</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 “{{.Query}}”.</p>
|
<p class="empty">No matches for “{{.Query}}”.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
+4
-3
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return results, builtAt
|
||||||
|
}
|
||||||
|
|
||||||
sort.SliceStable(results, func(i, j int) bool {
|
// searchFiles scores the cached file index against query, matching on filename
|
||||||
if results[i].Score != results[j].Score {
|
// only. Blocks on the initial build so the first request after startup doesn't
|
||||||
return results[i].Score > results[j].Score
|
// 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
|
||||||
}
|
}
|
||||||
di, dj := strings.Count(results[i].Path, "/"), strings.Count(results[j].Path, "/")
|
scored := scoreEntries(entries, query, false, func(e fileEntry) indexEntry { return e.indexEntry })
|
||||||
if di != dj {
|
results := make([]searchResult, 0, len(scored))
|
||||||
return di < dj
|
for _, s := range scored {
|
||||||
}
|
p := s.entry.Path
|
||||||
return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
|
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, builtAt
|
}
|
||||||
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user