Skip to content
quarry Artifact [3fc125d62f]
Login

Artifact [3fc125d62f]

Artifact 3fc125d62ff23796604079f39372f780bbb3baae96a202e2e81501411cd23e71:


package web

import (
	"bytes"
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"unicode/utf8"

	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/ops"
)

// Every search box lists what it would find as the reader types (#348).
// The boxes differ only in what they ask and where an item leads; the
// list is one component, so each box behaves and reads the same: the
// groups, the matched words marked, the ways to the full results last.

// suggestLimit is how many things a list offers. Each box asks its op
// for this many, so a suggestion is one index walk with a small limit.
const suggestLimit = 8

// suggestItem is one link in a list.
type suggestItem struct {
	URL string
	// Icon is the sprite symbol for the kind of thing, and Tone its
	// colour class: a ticket's icon says whether it is open, as it does
	// on every ticket link, so the list needs no status badge.
	Icon, Tone, IconLabel string
	// Code is what the reader would type to name it — a ticket's
	// number, a check-in's short hash — set beside the icon.
	Code string
	Text template.HTML
	Meta template.HTML
}

type suggestGroup struct {
	Name  string
	Items []suggestItem
}

// suggestList is one box's list. ID is the element the box names in
// aria-controls; the patch replaces it, and its count beside it.
type suggestList struct {
	ID, Hook string
	Q        string
	Groups   []suggestGroup
	// None is the heading shown when nothing matches.
	None string
	// More are the ways to the full results, after the items.
	More []suggestItem
}

func (l suggestList) count() int {
	n := 0
	for _, g := range l.Groups {
		n += len(g.Items)
	}
	return n
}

// add appends a group, keeping the list to suggestLimit items in all.
func (l *suggestList) add(name string, items []suggestItem) {
	room := suggestLimit - l.count()
	if room <= 0 || len(items) == 0 {
		return
	}
	l.Groups = append(l.Groups, suggestGroup{Name: name, Items: items[:min(len(items), room)]})
}

var suggestTmpl = page(template.Must(template.Must(base.Clone()).Funcs(template.FuncMap{
	"suggestCount": func(l suggestList) int { return l.count() },
}).Parse(
	`{{define "suggest"}}<div id="{{.ID}}" class="suggest" data-hook="{{.Hook}}" data-count="{{suggestCount .}}"
 data-on:keydown="(evt.key == 'ArrowDown' || evt.key == 'ArrowUp') &amp;&amp; (evt.preventDefault(), (l => (l[l.indexOf(document.activeElement) + (evt.key == 'ArrowDown' ? 1 : -1)] || (evt.key == 'ArrowUp' ? document.querySelector('[aria-controls={{.ID}}]') : l[0])).focus())([...el.querySelectorAll('a')])) || evt.key == 'Escape' &amp;&amp; document.querySelector('[aria-controls={{.ID}}]').focus()">
{{- if .Q}}{{if not .Groups}}<div class="sg-grp" data-hook="suggest-none">{{.None}}</div>{{end}}
{{- range .Groups}}<div class="sg-group" role="group" aria-label="{{.Name}}"><div class="sg-grp" aria-hidden="true">{{.Name}}</div>
{{- range .Items}}<a class="sg-it" href="{{.URL}}" data-hook="suggest-item" tabindex="0"><span class="sg-lead"><svg class="ic{{with .Tone}} {{.}}{{end}}" role="img" aria-label="{{.IconLabel}}"><use href="#{{.Icon}}"/></svg>{{with .Code}}<code>{{.}}</code>{{end}}</span><span class="sg-c">{{.Text}}</span>{{with .Meta}}<span class="sg-m">{{.}}</span>{{end}}</a>
{{- end}}</div>{{end}}
{{- range .More}}<a class="sg-all" href="{{.URL}}" data-hook="suggest-more" tabindex="0">{{.Text}} &rarr;</a>{{end}}{{end}}</div>
<span id="{{.ID}}-n" class="vh" role="status" aria-live="polite" data-hook="suggest-count">{{if .Q}}{{with suggestCount .}}{{.}} suggestion{{if ne . 1}}s{{end}}{{else}}no suggestions{{end}}{{end}}</span>{{end}}`)))

// writeSuggest sends a list as one Datastar patch of the list and its
// count.
func writeSuggest(w http.ResponseWriter, req *http.Request, l suggestList) {
	var buf bytes.Buffer
	if err := hooksOf(req.Context()).template(suggestTmpl).ExecuteTemplate(&buf, "suggest", l); err != nil {
		http.Error(w, "suggest render failed", http.StatusInternalServerError)
		return
	}
	markOneShot(w)
	w.Header().Set("Cache-Control", "no-store")
	patchElements(w, buf.String())
}

// markWords escapes s and marks each place a word of q appears in it,
// as the full results mark their snippets.
func markWords(s, q string) template.HTML {
	lower := strings.ToLower(s)
	var spans [][2]int
	for _, w := range strings.Fields(strings.ToLower(q)) {
		w = strings.Trim(w, `"#*`)
		if w == "" {
			continue
		}
		for at := 0; ; {
			i := strings.Index(lower[at:], w)
			if i < 0 {
				break
			}
			spans = append(spans, [2]int{at + i, at + i + len(w)})
			at += i + len(w)
		}
	}
	// Lowercasing can change a string's length; mark nothing rather
	// than cut a character in half.
	if len(lower) != len(s) || len(spans) == 0 {
		return template.HTML(template.HTMLEscapeString(s))
	}
	slices.SortFunc(spans, func(a, b [2]int) int { return a[0] - b[0] })
	var b strings.Builder
	at := 0
	for _, sp := range spans {
		if sp[0] < at {
			if sp[1] > at {
				sp[0] = at
			} else {
				continue
			}
		}
		b.WriteString(template.HTMLEscapeString(s[at:sp[0]]))
		b.WriteString("<mark>" + template.HTMLEscapeString(s[sp[0]:sp[1]]) + "</mark>")
		at = sp[1]
	}
	b.WriteString(template.HTMLEscapeString(s[at:]))
	return template.HTML(b.String())
}

// metaLine joins what tells items apart: plain words, escaped, and the
// date as the page's own relative time.
func metaLine(date string, words ...string) template.HTML {
	var parts []string
	for _, w := range words {
		if w = strings.TrimSpace(w); w != "" {
			parts = append(parts, template.HTMLEscapeString(w))
		}
	}
	if d := ago(date); d != "" {
		parts = append(parts, string(d))
	}
	return template.HTML(strings.Join(parts, " · "))
}

// kindIcons are the sprite symbols the site already uses for each kind.
var kindIcons = map[string][2]string{
	"checkin":  {"i-commit", "check-in"},
	"wiki":     {"i-book", "wiki page"},
	"doc":      {"i-book", "document"},
	"file":     {"i-file", "file"},
	"forum":    {"i-comment", "forum thread"},
	"technote": {"i-clock", "technote"},
}

// ticketIcon is a ticket's icon as every ticket link wears it: open, or
// done once its status closes it.
func ticketIcon(it *suggestItem, status string) {
	it.Icon, it.Tone, it.IconLabel = "i-tkt-open", "g-open", "ticket, open"
	if index.IsClosedStatus(status) {
		it.Icon, it.Tone, it.IconLabel = "i-tkt-done", "g-done", "ticket, closed"
	}
}

// hitItem is one search hit as a list item. base is the repository's
// own address, which the hit's link is rewritten onto.
func hitItem(repo, base string, m searchMeta, q string) suggestItem {
	h := hitOfMeta(repo, m)
	it := suggestItem{URL: base + strings.TrimPrefix(h.URL, "/r/"+repo), Text: markWords(h.Label, q)}
	if ic, ok := kindIcons[m.Kind]; ok {
		it.Icon, it.IconLabel = ic[0], ic[1]
	}
	switch m.Kind {
	case "ticket":
		ticketIcon(&it, m.Status)
		if m.Number > 0 {
			it.Code = "#" + strconv.Itoa(m.Number)
		} else {
			it.Code = m.Ref[:min(8, len(m.Ref))]
		}
		it.Meta = metaLine(m.Date)
	case "checkin":
		it.Code = h.Short
		it.Meta = metaLine(m.Date, m.User, m.Branch)
	case "doc", "file":
		// A path is its own name; the folder is shown, the version not.
		if i := strings.LastIndex(h.Label, "/"); i >= 0 {
			it.Text = markWords(h.Label[i+1:], q) + template.HTML(` <span class="sg-dir">`+template.HTMLEscapeString(h.Label[:i+1])+`</span>`)
		}
	default:
		it.Meta = metaLine(m.Date, m.User)
	}
	return it
}

var (
	ticketNumberQ = regexp.MustCompile(`^#?([1-9][0-9]{0,8})$`)
	hashPrefixQ   = regexp.MustCompile(`^[0-9a-fA-F]{4,64}$`)
)

// jumpTo is what a ticket number or a hash prefix names, listed first:
// the reader typing #348 or 2cda42 means that one thing. Nothing found
// is no jump; the search below still runs.
func jumpTo(r *ops.Registry, req *http.Request, repo, base, q string) (string, []suggestItem) {
	ctx := req.Context()
	if m := ticketNumberQ.FindStringSubmatch(q); m != nil {
		if out, err := invoke(ctx, r, "ticket.get", url.Values{"repo": {repo}, "id": {m[1]}}); err == nil {
			var t struct {
				Ticket struct {
					ID     string `json:"id"`
					Fields []struct{ Name, Value string }
				} `json:"ticket"`
				Number int `json:"number"`
			}
			remarshal(out, &t)
			if t.Number > 0 && strconv.Itoa(t.Number) == m[1] {
				it := suggestItem{URL: base + "/ticket/" + url.PathEscape(t.Ticket.ID), Code: "#" + m[1]}
				var status string
				for _, f := range t.Ticket.Fields {
					switch f.Name {
					case "title":
						it.Text = template.HTML(template.HTMLEscapeString(f.Value))
					case "status":
						status = f.Value
					}
				}
				ticketIcon(&it, status)
				return "Ticket #" + m[1], []suggestItem{it}
			}
		}
	}
	if !hashPrefixQ.MatchString(q) {
		return "", nil
	}
	prefix := strings.ToLower(q)
	out, err := invoke(ctx, r, "artifact.resolve", url.Values{"repo": {repo}, "prefix": {prefix}})
	if err != nil {
		return "", nil
	}
	var res struct{ Kind, Ref string }
	remarshal(out, &res)
	it := suggestItem{URL: base + "/info/" + prefix, Code: res.Ref[:min(12, len(res.Ref))]}
	switch res.Kind {
	case "ci":
		it.Icon, it.IconLabel = "i-commit", "check-in"
		if out, err := invoke(ctx, r, "checkin.get", url.Values{"repo": {repo}, "hash": {res.Ref}, "files": {"0"}}); err == nil {
			var cd checkinDetail
			remarshal(out, &cd)
			first, _, _ := strings.Cut(strings.TrimSpace(cd.Comment), "\n")
			it.Text = template.HTML(template.HTMLEscapeString(commentText(first)))
			it.Meta = metaLine(cd.Date, cd.User, cd.Branch)
		}
	case "t":
		if out, err := invoke(ctx, r, "ticket.get", url.Values{"repo": {repo}, "id": {res.Ref}}); err == nil {
			var t struct {
				Ticket struct {
					Fields []struct{ Name, Value string }
				} `json:"ticket"`
				Number int `json:"number"`
			}
			remarshal(out, &t)
			var status string
			for _, f := range t.Ticket.Fields {
				switch f.Name {
				case "title":
					it.Text = template.HTML(template.HTMLEscapeString(f.Value))
				case "status":
					status = f.Value
				}
			}
			ticketIcon(&it, status)
			if t.Number > 0 {
				it.Code = "#" + strconv.Itoa(t.Number)
			}
		}
	case "w":
		it.Icon, it.IconLabel, it.Code = "i-book", "wiki page", ""
		it.Text = template.HTML(template.HTMLEscapeString(res.Ref))
	case "e":
		it.Icon, it.IconLabel = "i-clock", "technote"
	case "f":
		it.Icon, it.IconLabel = "i-comment", "forum thread"
	default:
		return "", nil
	}
	if it.Icon == "" {
		ticketIcon(&it, "")
	}
	if it.Text == "" {
		it.Text = template.HTML(it.IconLabel)
	}
	return "Hash " + prefix, []suggestItem{it}
}

// suggestQuery is the box's words, bounded as the search op bounds them.
func suggestQuery(req *http.Request) string {
	q := strings.TrimSpace(req.URL.Query().Get("q"))
	if utf8.RuneCountInString(q) > 200 {
		return ""
	}
	return q
}

// repoSuggest answers a repository's header box: what its section's
// search would find, then the full results, then everything.
func repoSuggest(r *ops.Registry, w http.ResponseWriter, req *http.Request) {
	repo := req.PathValue("repo")
	kind := req.URL.Query().Get("kind")
	if kind != "" && !slices.Contains(index.SearchKinds, kind) {
		fail(r, w, req, fmt.Errorf("no such search scope %q: %w", kind, ops.ErrNotFound))
		return
	}
	q := suggestQuery(req)
	l := suggestList{ID: "find-suggest", Hook: "search-suggest", Q: q}
	if q != "" {
		vals := url.Values{"repo": {repo}, "q": {q}, "limit": {strconv.Itoa(suggestLimit)}, "brief": {"true"}}
		if kind != "" {
			vals.Set("kind", kind)
		}
		out, err := invoke(req.Context(), r, "search.query", vals)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var sr struct {
			Results []searchMeta `json:"results"`
		}
		remarshal(out, &sr)
		base := hooksOf(req.Context()).repoURL(repo)
		if name, items := jumpTo(r, req, repo, base, q); len(items) > 0 {
			l.add(name, items)
		}
		// One group per kind, in the order the results page offers them.
		for _, sc := range searchScopes[1:] {
			var items []suggestItem
			for _, m := range sr.Results {
				if m.Kind == sc.Kind {
					items = append(items, hitItem(repo, base, m, q))
				}
			}
			l.add(sc.Label, items)
		}
		_, scope := scopeWord(kind)
		l.None = "Nothing in " + scope + " matches “" + q + "”"
		all := base + "/search?q=" + url.QueryEscape(q)
		if kind != "" {
			l.More = append(l.More, suggestItem{URL: all + "&kind=" + kind,
				Text: template.HTML(template.HTMLEscapeString("All results for “" + q + "” in " + scope))})
			l.More = append(l.More, suggestItem{URL: all,
				Text: template.HTML(template.HTMLEscapeString("Everything in " + repo + " for “" + q + "”"))})
		} else {
			l.More = append(l.More, suggestItem{URL: all,
				Text: template.HTML(template.HTMLEscapeString("All results for “" + q + "”"))})
		}
	}
	writeSuggest(w, req, l)
}

// scopeWord names a search kind for a sentence, "everything" for none.
func scopeWord(kind string) (string, string) {
	for _, sc := range searchScopes {
		if sc.Kind == kind {
			return kind, sc.Label
		}
	}
	return "", "everything"
}

// fleetSuggest answers the header box off a repository: what a search
// of every repository the reader may read would find, each item saying
// which repository it is in.
func fleetSuggest(r *ops.Registry, w http.ResponseWriter, req *http.Request) {
	kind := req.URL.Query().Get("kind")
	if kind != "" && !slices.Contains(index.SearchKinds, kind) {
		fail(r, w, req, fmt.Errorf("no such search scope %q: %w", kind, ops.ErrNotFound))
		return
	}
	q := suggestQuery(req)
	l := suggestList{ID: "find-suggest", Hook: "search-suggest", Q: q}
	if q != "" {
		vals := url.Values{"q": {q}, "limit": {strconv.Itoa(suggestLimit)}, "brief": {"true"}}
		if kind != "" {
			vals.Set("kind", kind)
		}
		only := req.URL.Query().Get("repo")
		if only != "" {
			vals.Set("repo", only)
		}
		out, err := invoke(req.Context(), r, "search.all", vals)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var sr struct {
			Results []struct {
				Repo string `json:"repo"`
				searchMeta
			} `json:"results"`
		}
		remarshal(out, &sr)
		h := hooksOf(req.Context())
		var items []suggestItem
		for _, res := range sr.Results {
			it := hitItem(res.Repo, h.repoURL(res.Repo), res.searchMeta, q)
			repoTag := template.HTML(`<span class="sg-repo">` + template.HTMLEscapeString(res.Repo) + `</span>`)
			if it.Meta != "" {
				it.Meta = repoTag + " · " + it.Meta
			} else {
				it.Meta = repoTag
			}
			items = append(items, it)
		}
		_, scope := scopeWord(kind)
		name := "All repositories"
		if only != "" {
			name = only
		}
		l.add(name, items)
		l.None = "Nothing in any repository matches “" + q + "”"
		all := "/search?q=" + url.QueryEscape(q)
		if kind != "" {
			all += "&kind=" + kind
		}
		if only != "" {
			all += "&repo=" + url.QueryEscape(only)
		}
		text := "All results for “" + q + "”"
		if kind != "" {
			text += " in " + scope
		}
		l.More = []suggestItem{{URL: all, Text: template.HTML(template.HTMLEscapeString(text))}}
	}
	writeSuggest(w, req, l)
}

// timelineSuggest answers the timeline box as the reader types: what
// Enter would find, under the page's filters. Each item opens the
// timeline centred on that check-in, keeping the search so the reader
// can step on from there, and the page's filters.
func timelineSuggest(r *ops.Registry, w http.ResponseWriter, req *http.Request) {
	repo := req.PathValue("repo")
	vals, _ := timelineQuery(req.URL.Query(), repo)
	q := vals.Get("find")
	base := hooksOf(req.Context()).repoURL(repo)
	l := suggestList{ID: "tl-suggest", Hook: "timeline-suggest", Q: q}
	if q != "" {
		vals.Set("suggest", "true")
		out, err := invoke(req.Context(), r, "timeline.list", vals)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var res struct {
			Suggestions []timelineEntry `json:"suggestions"`
		}
		remarshal(out, &res)
		keep := "&q=" + url.QueryEscape(q)
		if p := vals.Get("path"); p != "" {
			keep += "&p=" + url.QueryEscape(p)
		}
		if b := vals.Get("branch"); b != "" {
			keep += "&branch=" + url.QueryEscape(b)
		}
		var items []suggestItem
		for _, e := range res.Suggestions {
			first, _, _ := strings.Cut(strings.TrimSpace(e.Comment), "\n")
			items = append(items, suggestItem{URL: base + "/timeline?c=" + e.Hash + keep,
				Icon: "i-commit", IconLabel: "check-in", Code: e.Hash[:min(12, len(e.Hash))],
				Text: markWords(commentText(first), q), Meta: metaLine(e.Date, e.User, e.Branch)})
		}
		l.add("Check-ins", items)
		l.None = "Nothing on the timeline matches “" + q + "”"
		l.More = []suggestItem{{URL: base + "/search?q=" + url.QueryEscape(q),
			Text: template.HTML(template.HTMLEscapeString("All results for “" + q + "” on the search page"))}}
	}
	writeSuggest(w, req, l)
}

// peopleSuggest answers the people filters: the fleet's at /people, and
// one repository's when the route names one. Each person opens where
// the list beneath the filter would send them.
func peopleSuggest(r *ops.Registry, w http.ResponseWriter, req *http.Request) {
	repo := req.PathValue("repo")
	q := suggestQuery(req)
	l := suggestList{ID: "people-suggest", Hook: "people-suggest", Q: q}
	if q != "" {
		vals := url.Values{"q": {q}, "limit": {strconv.Itoa(suggestLimit)}}
		if repo != "" {
			vals.Set("repo", repo)
		}
		out, err := invoke(req.Context(), r, "people.list", vals)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var res struct {
			People []struct {
				User   string   `json:"user"`
				Events int      `json:"events"`
				Last   string   `json:"last"`
				Repos  []string `json:"repos"`
			} `json:"people"`
		}
		remarshal(out, &res)
		base := ""
		if repo != "" {
			base = hooksOf(req.Context()).repoURL(repo)
		}
		var items []suggestItem
		for _, p := range res.People {
			it := suggestItem{Icon: "i-person", IconLabel: "person", Text: markWords(p.User, q)}
			if repo == "" {
				it.URL = "/people/" + url.PathEscape(p.User)
				it.Meta = metaLine(p.Last, counted(len(p.Repos), "repository", "repositories"))
			} else {
				it.URL = base + "/timeline?user=" + url.QueryEscape(p.User) + "&y=all"
				it.Meta = metaLine(p.Last, counted(p.Events, "contribution", "contributions"))
			}
			items = append(items, it)
		}
		l.add("People", items)
		l.None = "Nobody matches “" + q + "”"
		l.More = []suggestItem{{URL: base + "/people?q=" + url.QueryEscape(q),
			Text: template.HTML(template.HTMLEscapeString("Everyone matching “" + q + "”"))}}
	}
	writeSuggest(w, req, l)
}

// counted words a count with its noun, for nouns plural alone cannot
// make ("repository").
func counted(n int, one, many string) string {
	if n == 1 {
		return "1 " + one
	}
	return strconv.Itoa(n) + " " + many
}

// filesSuggest answers the header box on the Files page: paths in the
// tree being read that hold the words, each opening the file at that
// check-in, then the files' contents and everything. Enter searches the
// files, as the box does on any other tree page.
func filesSuggest(r *ops.Registry, w http.ResponseWriter, req *http.Request) {
	repo := req.PathValue("repo")
	q := suggestQuery(req)
	l := suggestList{ID: "find-suggest", Hook: "files-suggest", Q: q}
	if q != "" {
		out, err := invoke(req.Context(), r, "tree.find", url.Values{"repo": {repo},
			"version": {req.PathValue("version")}, "q": {q}, "limit": {strconv.Itoa(suggestLimit)}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var res struct {
			Version string   `json:"version"`
			Paths   []string `json:"paths"`
		}
		remarshal(out, &res)
		base := hooksOf(req.Context()).repoURL(repo)
		var items []suggestItem
		for _, p := range res.Paths {
			segs := strings.Split(p, "/")
			for i := range segs {
				segs[i] = url.PathEscape(segs[i])
			}
			it := suggestItem{URL: base + "/file/" + res.Version + "/" + strings.Join(segs, "/"),
				Icon: "i-file", IconLabel: "file", Text: markWords(p, q)}
			if i := strings.LastIndexByte(p, '/'); i >= 0 {
				it.Text = markWords(p[i+1:], q) + template.HTML(` <span class="sg-dir">`+template.HTMLEscapeString(p[:i+1])+`</span>`)
			}
			items = append(items, it)
		}
		if name, jump := jumpTo(r, req, repo, base, q); len(jump) > 0 {
			l.add(name, jump)
		}
		l.add("Files", items)
		l.None = "No path matches “" + q + "”"
		all := base + "/search?q=" + url.QueryEscape(q)
		l.More = []suggestItem{
			{URL: all + "&kind=file", Text: template.HTML(template.HTMLEscapeString("Search file contents for “" + q + "”"))},
			{URL: all, Text: template.HTML(template.HTMLEscapeString("Everything in " + repo + " for “" + q + "”"))},
		}
	}
	writeSuggest(w, req, l)
}