fix(search): qualifiers and case insensitive languages
5 files changed, +125 −21
- README.md modified +1 −1
- search.go modified +91 −4
- static/style.css modified +17 −8
- templates/repo.html modified +1 −1
- templates/search.html modified +15 −7
README.md modified
| @@ -14,7 +14,7 @@ Small and lightweight git server for self hosting. Repositories are served over | ||
| 14 | 14 | - A per user disk quota set with `gitpocket quota 2G` and enforced on push over both transports |
| 15 | 15 | - Signature verification for both SSH and PGP. Register the key in settings, sign commits with it, and they show `Verified ~you`. PGP keys are imported into a server keyring and matched by primary fingerprint, SSH keys through an allowed signers file matched by fingerprint. Keys are public at `~you.keys` and `~you.pgp` |
| 16 | 16 | - Issues per repository with Markdown bodies and comments, open and closed states, and an open count in the tab strip |
| 17 | -- A site wide search at `/search` over repositories and people, ranked so an exact name beats a prefix beats a substring. Each repository's languages come from the byte size of its files at HEAD by extension, skipping minified and vendored paths, and are cached against the HEAD hash so the language filter and the breakdown bar on the summary page cost one `git ls-tree` per push | |
| 17 | +- A site wide search at `/search` over repositories and people, ranked so an exact name beats a prefix beats a substring. Qualifiers narrow it the way they do elsewhere, `language:ocaml` (case insensitive, quoted when the name has a space), `user:name` and `tag:sel4`, and the language facets write the qualifier into the query box so the filter survives the next search. Each repository's languages come from the byte size of its files at HEAD by extension, skipping minified and vendored paths, and are cached against the HEAD hash so the language filter and the breakdown bar on the summary page cost one `git ls-tree` per push | |
| 18 | 18 | - Stars, so a profile shows what someone found worth keeping, and a star count next to every repository title |
| 19 | 19 | - A year of activity on each profile, drawn server side as a grid with no script, with a tab for the past twelve months and one for each calendar year that has commits. Hovering or focusing a day shows its count in a CSS tooltip. It counts commits on every branch of every public repository whose author email matches the account's verified address, so work on other people's projects counts and nothing is attributed by name alone |
| 20 | 20 | - Blame, a compare page for any two refs, and tar.gz and zip downloads of any ref. The tar.gz is `git archive --format=tar` passed through a gzip writer with a zeroed header and a fixed level, so the same commit always produces the same bytes and `archive/<ref>.tar.gz.sha256` publishes the checksum that a package recipe can pin |
search.go modified
| @@ -127,6 +127,14 @@ type langCount struct { | ||
| 127 | 127 | Name string |
| 128 | 128 | Colour string |
| 129 | 129 | Count int |
| 130 | + Query string | |
| 131 | +} | |
| 132 | + | |
| 133 | +func langQualifier(name string) string { | |
| 134 | + if strings.ContainsRune(name, ' ') { | |
| 135 | + return `language:"` + name + `"` | |
| 136 | + } | |
| 137 | + return "language:" + name | |
| 130 | 138 | } |
| 131 | 139 | |
| 132 | 140 | func matchScore(q string, fields ...string) int { |
| @@ -152,9 +160,15 @@ func matchScore(q string, fields ...string) int { | ||
| 152 | 160 | func (s *Server) find(w http.ResponseWriter, r *http.Request) { |
| 153 | 161 | query := strings.TrimSpace(r.URL.Query().Get("q")) |
| 154 | 162 | kind := r.URL.Query().Get("type") |
| 163 | + text, quals := parseQuery(query) | |
| 155 | 164 | lang := r.URL.Query().Get("lang") |
| 165 | + if l, ok := quals["language"]; ok { | |
| 166 | + lang = l | |
| 167 | + } | |
| 168 | + lang = canonicalLanguage(lang) | |
| 169 | + owner, tag := quals["user"], strings.ToLower(quals["tag"]) | |
| 156 | 170 | sort := r.URL.Query().Get("sort") |
| 157 | - q := strings.ToLower(query) | |
| 171 | + q := strings.ToLower(text) | |
| 158 | 172 | if kind != "users" { |
| 159 | 173 | kind = "repos" |
| 160 | 174 | } |
| @@ -169,7 +183,7 @@ func (s *Server) find(w http.ResponseWriter, r *http.Request) { | ||
| 169 | 183 | names = append(names, l.Name) |
| 170 | 184 | } |
| 171 | 185 | score := matchScore(q, repo.Name, repo.Owner.Name, strings.Join(repo.Tags, " "), strings.Join(names, " "), repo.Description) |
| 172 | - if score == 0 { | |
| 186 | + if score == 0 || (owner != "" && !strings.EqualFold(owner, repo.Owner.Name)) || (tag != "" && !slices.Contains(repo.Tags, tag)) { | |
| 173 | 187 | continue |
| 174 | 188 | } |
| 175 | 189 | hit := repoHit{Repo: repo, score: score} |
| @@ -203,9 +217,15 @@ func (s *Server) find(w http.ResponseWriter, r *http.Request) { | ||
| 203 | 217 | }) |
| 204 | 218 | } |
| 205 | 219 | var facets []langCount |
| 220 | + base := strings.TrimSpace(stripLanguage(query)) | |
| 206 | 221 | for _, c := range counts { |
| 222 | + c.Query = strings.TrimSpace(base + " " + langQualifier(c.Name)) | |
| 207 | 223 | facets = append(facets, *c) |
| 208 | 224 | } |
| 225 | + box := base | |
| 226 | + if lang != "" { | |
| 227 | + box = strings.TrimSpace(base + " " + langQualifier(lang)) | |
| 228 | + } | |
| 209 | 229 | slices.SortFunc(facets, func(a, b langCount) int { |
| 210 | 230 | if a.Count != b.Count { |
| 211 | 231 | return b.Count - a.Count |
| @@ -217,7 +237,7 @@ func (s *Server) find(w http.ResponseWriter, r *http.Request) { | ||
| 217 | 237 | for _, u := range s.store.SearchableUsers() { |
| 218 | 238 | p := s.store.Profile(u.ID) |
| 219 | 239 | score := matchScore(q, u.Name, p.Display, p.Location, p.Workplace, p.Bio) |
| 220 | - if score == 0 { | |
| 240 | + if score == 0 || (owner != "" && !strings.EqualFold(owner, u.Name)) { | |
| 221 | 241 | continue |
| 222 | 242 | } |
| 223 | 243 | owned, _ := s.store.ReposByOwner(u.ID, false) |
| @@ -259,6 +279,73 @@ func (s *Server) find(w http.ResponseWriter, r *http.Request) { | ||
| 259 | 279 | title = query + " · search" |
| 260 | 280 | } |
| 261 | 281 | s.render(w, r, "search", page{Title: title, User: s.current(r), Data: map[string]any{ |
| 262 | - "Query": query, "Type": kind, "Lang": lang, "Sort": sort, "Repos": repos, "Users": users, "Languages": facets, "Facets": true, | |
| 282 | + "Query": box, "Base": base, "Type": kind, "Lang": lang, "Sort": sort, "Repos": repos, "Users": users, "Languages": facets, "Facets": true, | |
| 263 | 283 | }}) |
| 264 | 284 | } |
| 285 | + | |
| 286 | +var qualifiers = map[string]string{"language": "language", "lang": "language", "user": "user", "owner": "user", "tag": "tag", "topic": "tag"} | |
| 287 | + | |
| 288 | +func queryTokens(query string) []string { | |
| 289 | + var tokens []string | |
| 290 | + var cur strings.Builder | |
| 291 | + quoted := false | |
| 292 | + for _, r := range query { | |
| 293 | + switch { | |
| 294 | + case r == '"': | |
| 295 | + quoted = !quoted | |
| 296 | + case r == ' ' && !quoted: | |
| 297 | + if cur.Len() > 0 { | |
| 298 | + tokens = append(tokens, cur.String()) | |
| 299 | + cur.Reset() | |
| 300 | + } | |
| 301 | + default: | |
| 302 | + cur.WriteRune(r) | |
| 303 | + } | |
| 304 | + } | |
| 305 | + if cur.Len() > 0 { | |
| 306 | + tokens = append(tokens, cur.String()) | |
| 307 | + } | |
| 308 | + return tokens | |
| 309 | +} | |
| 310 | + | |
| 311 | +func parseQuery(query string) (string, map[string]string) { | |
| 312 | + quals := map[string]string{} | |
| 313 | + var words []string | |
| 314 | + for _, tok := range queryTokens(query) { | |
| 315 | + key, value, ok := strings.Cut(tok, ":") | |
| 316 | + if name, known := qualifiers[strings.ToLower(key)]; ok && known && value != "" { | |
| 317 | + quals[name] = strings.TrimPrefix(value, "~") | |
| 318 | + continue | |
| 319 | + } | |
| 320 | + words = append(words, tok) | |
| 321 | + } | |
| 322 | + return strings.Join(words, " "), quals | |
| 323 | +} | |
| 324 | + | |
| 325 | +func stripLanguage(query string) string { | |
| 326 | + var keep []string | |
| 327 | + for _, tok := range queryTokens(query) { | |
| 328 | + key, _, ok := strings.Cut(tok, ":") | |
| 329 | + if ok && qualifiers[strings.ToLower(key)] == "language" { | |
| 330 | + continue | |
| 331 | + } | |
| 332 | + if strings.ContainsRune(tok, ' ') { | |
| 333 | + k, v, _ := strings.Cut(tok, ":") | |
| 334 | + tok = k + `:"` + v + `"` | |
| 335 | + } | |
| 336 | + keep = append(keep, tok) | |
| 337 | + } | |
| 338 | + return strings.Join(keep, " ") | |
| 339 | +} | |
| 340 | + | |
| 341 | +func canonicalLanguage(name string) string { | |
| 342 | + if name == "" { | |
| 343 | + return "" | |
| 344 | + } | |
| 345 | + for _, l := range languages { | |
| 346 | + if strings.EqualFold(l[0], name) { | |
| 347 | + return l[0] | |
| 348 | + } | |
| 349 | + } | |
| 350 | + return name | |
| 351 | +} | |
static/style.css modified
| @@ -362,13 +362,6 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 362 | 362 | .navsearch{flex:0 1 20rem;margin-left:.5rem} |
| 363 | 363 | .navsearch input{width:100%;padding:.2rem .55rem;font-size:.9rem} |
| 364 | 364 | .searchpage{display:grid;grid-template-columns:15rem 1fr;gap:2rem;align-items:start} |
| 365 | -.facets h4{font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 .4rem} | |
| 366 | -.facets h4+nav{margin-bottom:1.2rem} | |
| 367 | -.facetlist{display:flex;flex-direction:column} | |
| 368 | -.facetlist a{display:flex;justify-content:space-between;align-items:center;padding:.3rem .55rem;color:var(--fg);border-radius:4px} | |
| 369 | -.facetlist a:hover{background:var(--soft);text-decoration:none} | |
| 370 | -.facetlist a.active{background:var(--soft);font-weight:600;box-shadow:inset 3px 0 0 var(--repo)} | |
| 371 | -.facetlist .count{background:#e9ecef;color:var(--muted);font-size:.75rem;padding:0 .45rem;border-radius:9px;font-weight:400} | |
| 372 | 365 | .dot{display:inline-block;width:.7rem;height:.7rem;border-radius:50%;margin-right:.4rem;vertical-align:-.05rem;border:1px solid rgba(0,0,0,.12)} |
| 373 | 366 | .bigsearch{display:flex;gap:.5rem;margin:0 0 1.2rem 17rem} |
| 374 | 367 | .bigsearch input{flex:1;font-size:1rem;padding:.45rem .7rem} |
| @@ -392,4 +385,20 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 392 | 385 | .langbar span{display:block;height:100%} |
| 393 | 386 | .langlist{list-style:none;margin:0 0 1.2rem;padding:0;font-size:.85rem;display:flex;flex-wrap:wrap;gap:.2rem 1rem} |
| 394 | 387 | .langlist .pct{color:var(--muted);margin-left:.25rem} |
| 395 | -@media(max-width:800px){.bigsearch{margin-left:0}.searchpage{grid-template-columns:1fr;gap:.6rem}.navsearch{order:5;flex-basis:100%;margin:0}.facets h4{display:none}.facets h4+nav{margin-bottom:.4rem}.facetlist{flex-direction:row;flex-wrap:wrap;gap:.35rem}.facetlist a{border:1px solid var(--line);padding:.15rem .5rem;gap:.4rem}.facetlist a.active{box-shadow:none;border-color:var(--repo)}} | |
| 388 | +@media(max-width:800px){.bigsearch{margin-left:0}.searchpage{grid-template-columns:1fr;gap:.6rem}.navsearch{order:5;flex-basis:100%;margin:0}.facets{display:flex;flex-direction:column;gap:.5rem}.facetbox{padding:.4rem}.facetbox h4,.facetbox.tips{display:none}.facetlist{flex-direction:row;flex-wrap:wrap;gap:.35rem}.facetlist a{border:1px solid var(--line);padding:.2rem .55rem}.facetlist a.active{border-color:var(--repo);box-shadow:none}} | |
| 389 | +.facetbox{border:1px solid var(--line);border-radius:6px;padding:.5rem;margin-bottom:1rem} | |
| 390 | +.facetbox h4{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:.15rem .4rem .45rem;font-weight:600} | |
| 391 | +.facetlist{display:flex;flex-direction:column;gap:1px} | |
| 392 | +.facetlist a{display:flex;align-items:center;gap:.55rem;padding:.35rem .45rem;color:var(--fg);border-radius:4px;font-size:.93rem} | |
| 393 | +.facetlist a:hover{background:var(--soft);text-decoration:none} | |
| 394 | +.facetlist a.active{background:#e8eef8;color:var(--repo);font-weight:600} | |
| 395 | +.facetlist svg{width:16px;height:16px;flex:none;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round;color:var(--muted)} | |
| 396 | +.facetlist a.active svg{color:var(--repo)} | |
| 397 | +.facetlist .dot{margin:0 .1rem 0 .15rem;width:.65rem;height:.65rem} | |
| 398 | +.facetlist .fname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| 399 | +.facetlist .count{background:none;padding:0;color:var(--muted);font-size:.8rem;font-variant-numeric:tabular-nums;font-weight:400} | |
| 400 | +.clearfacet{display:block;font-size:.8rem;color:var(--muted);margin:.4rem .45rem .1rem} | |
| 401 | +.facetbox.tips p{margin:0 .4rem .2rem;display:flex;flex-wrap:wrap;gap:.3rem} | |
| 402 | +.facetbox.tips code{font-size:.75rem;background:var(--soft);padding:.05rem .3rem;border-radius:3px} | |
| 403 | +@media(max-width:800px){.facetbox{border:0;padding:0;margin:0}.facetlist a{border-radius:14px}.facets{gap:.35rem}} | |
| 404 | +@media(max-width:800px){.facetlist{flex-direction:row;flex-wrap:wrap;gap:.35rem}.facetlist .fname{flex:none}.facetlist a{gap:.4rem}.facets{flex-direction:row;flex-wrap:wrap}} | |
templates/repo.html modified
| @@ -17,7 +17,7 @@ git push -u origin {{$p.Ref}}</pre> | ||
| 17 | 17 | <div class="clone"> |
| 18 | 18 | {{with .Data.Languages}}<h3>languages</h3> |
| 19 | 19 | <div class="langbar">{{range .}}<span style="width:{{printf "%.2f" .Percent}}%;background:{{.Colour}}" title="{{.Name}} {{printf "%.1f" .Percent}}%"></span>{{end}}</div> |
| 20 | -<ul class="langlist">{{range .}}<li><a href="/search?lang={{.Name}}"><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}</a><span class="pct">{{printf "%.1f" .Percent}}%</span></li>{{end}}</ul> | |
| 20 | +<ul class="langlist">{{range .}}<li><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}<span class="pct">{{printf "%.1f" .Percent}}%</span></li>{{end}}</ul> | |
| 21 | 21 | <h3 class="rule">clone</h3>{{else}}<h3>clone</h3>{{end}} |
| 22 | 22 | <strong>read-only</strong> |
| 23 | 23 | <div><a href="https://{{.Host}}/{{$p.Repo.Path}}">https://{{.Host}}/{{$p.Repo.Path}}</a></div> |
templates/search.html modified
| @@ -1,21 +1,29 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$d := .Data}} |
| 3 | 3 | <div class="searchwrap"> |
| 4 | -<form class="bigsearch" method="get" action="/search"><input name="q" value="{{$d.Query}}" placeholder="Search repositories, people, languages" autofocus><input type="hidden" name="type" value="{{$d.Type}}">{{with $d.Lang}}<input type="hidden" name="lang" value="{{.}}">{{end}}<button class="btn primary">Search</button></form> | |
| 4 | +<form class="bigsearch" method="get" action="/search"><input name="q" value="{{$d.Query}}" placeholder="Search repositories, people, languages" autofocus><input type="hidden" name="type" value="{{$d.Type}}"><button class="btn primary">Search</button></form> | |
| 5 | 5 | <div class="searchpage"> |
| 6 | 6 | <aside class="facets"> |
| 7 | +<section class="facetbox"> | |
| 7 | 8 | <h4>Filter by</h4> |
| 8 | 9 | <nav class="facetlist"> |
| 9 | -<a href="?q={{$d.Query}}&type=repos{{with $d.Lang}}&lang={{.}}{{end}}"{{if eq $d.Type "repos"}} class="active"{{end}}><span>Repositories</span><span class="count">{{len $d.Repos}}</span></a> | |
| 10 | -<a href="?q={{$d.Query}}&type=users{{with $d.Lang}}&lang={{.}}{{end}}"{{if eq $d.Type "users"}} class="active"{{end}}><span>Users</span><span class="count">{{len $d.Users}}</span></a> | |
| 10 | +<a href="?q={{$d.Query}}&type=repos"{{if eq $d.Type "repos"}} class="active"{{end}}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 2.5h8.5a1 1 0 0 1 1 1v9.5H4.5A1.5 1.5 0 0 1 3 11.5z"/><path d="M3 11.5A1.5 1.5 0 0 1 4.5 10h8"/></svg><span class="fname">Repositories</span><span class="count">{{len $d.Repos}}</span></a> | |
| 11 | +<a href="?q={{$d.Query}}&type=users"{{if eq $d.Type "users"}} class="active"{{end}}><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="2.7"/><path d="M2.8 13.5c.6-2.6 2.7-4 5.2-4s4.6 1.4 5.2 4"/></svg><span class="fname">Users</span><span class="count">{{len $d.Users}}</span></a> | |
| 11 | 12 | </nav> |
| 12 | -{{if $d.Languages}}<h4>Languages</h4> | |
| 13 | -<nav class="facetlist">{{range $d.Languages}}<a href="?q={{$d.Query}}&type={{$d.Type}}&lang={{.Name}}"{{if eq .Name $d.Lang}} class="active"{{end}}><span><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}</span><span class="count">{{.Count}}</span></a>{{end}}</nav> | |
| 14 | -{{with $d.Lang}}<p class="small"><a href="?q={{$d.Query}}&type={{$d.Type}}">Clear language</a></p>{{end}}{{end}} | |
| 13 | +</section> | |
| 14 | +{{if $d.Languages}}<section class="facetbox"> | |
| 15 | +<h4>Languages</h4> | |
| 16 | +<nav class="facetlist">{{range $d.Languages}}<a href="?q={{.Query}}&type={{$d.Type}}"{{if eq .Name $d.Lang}} class="active"{{end}}><i class="dot" style="background:{{.Colour}}"></i><span class="fname">{{.Name}}</span><span class="count">{{.Count}}</span></a>{{end}}</nav> | |
| 17 | +{{with $d.Lang}}<a class="clearfacet" href="?q={{$d.Base}}&type={{$d.Type}}">× Clear {{.}}</a>{{end}} | |
| 18 | +</section>{{end}} | |
| 19 | +<section class="facetbox tips"> | |
| 20 | +<h4>Qualifiers</h4> | |
| 21 | +<p><code>language:ocaml</code> <code>user:james</code> <code>tag:sel4</code></p> | |
| 22 | +</section> | |
| 15 | 23 | </aside> |
| 16 | 24 | <div class="results"> |
| 17 | 25 | {{if eq $d.Type "repos"}} |
| 18 | -<div class="resulthead"><h3 class="plain">{{len $d.Repos}} repositor{{if eq (len $d.Repos) 1}}y{{else}}ies{{end}}{{with $d.Query}} matching <q>{{.}}</q>{{end}}{{with $d.Lang}} in {{.}}{{end}}</h3> | |
| 26 | +<div class="resulthead"><h3 class="plain">{{len $d.Repos}} repositor{{if eq (len $d.Repos) 1}}y{{else}}ies{{end}}{{with $d.Query}} matching <q>{{.}}</q>{{end}}</h3> | |
| 19 | 27 | <nav class="sorts"><a href="?q={{$d.Query}}&type=repos{{with $d.Lang}}&lang={{.}}{{end}}"{{if not $d.Sort}} class="active"{{end}}>Best match</a><a href="?q={{$d.Query}}&type=repos&sort=updated{{with $d.Lang}}&lang={{.}}{{end}}"{{if eq $d.Sort "updated"}} class="active"{{end}}>Recently updated</a><a href="?q={{$d.Query}}&type=repos&sort=stars{{with $d.Lang}}&lang={{.}}{{end}}"{{if eq $d.Sort "stars"}} class="active"{{end}}>Most stars</a></nav></div> |
| 20 | 28 | {{range $d.Repos}}<div class="hit"><h4><a class="owner" href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a></h4>{{with .Description}}<p>{{.}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/projects?tag={{.}}">#{{.}}</a>{{end}}</p>{{end}}<p class="meta">{{with .Language}}<span><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}</span>{{end}}{{if .Stars}}<span>☆ {{.Stars}}</span>{{end}}<span>Updated {{ago .Updated}}</span></p></div>{{else}}<p class="info">No repositories match. Try a shorter query or clear the language filter.</p>{{end}} |
| 21 | 29 | {{else}} |