Skip to content
quarry Artifact [11d2ade81a]
Login

Artifact [11d2ade81a]

Artifact 11d2ade81a0771dfdb69f093785195e439370038c3aeb1860505c0cefaaf0428:


package api

import (
	"strings"

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

// localTicketHits is the local tier's answer to a ticket search: its
// tickets holding every word of q, as search results. The index has
// never heard of them — they live in the forge's own store until they
// are promoted (docs/local-tickets.md) — so a search for a ticket filed
// here found nothing, while every recent ticket on a forge that files
// locally is one of these.
//
// Matched in Go, as ticket.list narrows them: the intake queue is
// dozens of rows where the archive is thousands. Two lists, because a
// word in the title says more than one in the body: titled hits lead
// the results and body hits follow the index's.
func (d Deps) localTicketHits(repo, q string) (titled, body []index.SearchResult) {
	if d.Local == nil {
		return nil, nil
	}
	words := strings.Fields(strings.ToLower(q))
	for i, w := range words {
		words[i] = strings.Trim(w, `"*`)
	}
	if len(words) == 0 {
		return nil, nil
	}
	ls, err := d.Local.LocalTickets(repo)
	if err != nil {
		return nil, nil
	}
	for _, lt := range ls {
		title := lt.Fields["title"]
		text := lt.Fields["comment"]
		for _, c := range lt.Comments {
			text += "\n" + c.Body
		}
		lowTitle, lowAll := strings.ToLower(title), strings.ToLower(title+"\n"+text)
		inTitle, inAll := true, true
		for _, w := range words {
			if w == "" {
				continue
			}
			if !strings.Contains(lowTitle, w) {
				inTitle = false
			}
			if !strings.Contains(lowAll, w) {
				inAll = false
				break
			}
		}
		if !inAll {
			continue
		}
		hit := index.SearchResult{Kind: "ticket", Ref: lt.ID, Label: title,
			Snippet: localSnippet(text, words), Date: lt.Updated, Status: lt.Fields["status"]}
		if inTitle {
			// Ahead of every index hit, the way a fleet search merges by
			// rank: bm25 is negative and lower is better.
			hit.Rank = -1e9
			titled = append(titled, hit)
		} else {
			body = append(body, hit)
		}
	}
	return titled, body
}

// localSnippet is the stretch of text around the first word found,
// marked the way the index marks its snippets, so the results page
// renders both alike.
func localSnippet(text string, words []string) string {
	text = strings.Join(strings.Fields(text), " ")
	low := strings.ToLower(text)
	at, w := -1, ""
	for _, cand := range words {
		if i := strings.Index(low, cand); i >= 0 && (at < 0 || i < at) && cand != "" {
			at, w = i, cand
		}
	}
	if at < 0 || len(low) != len(text) {
		// No word in the body (the title matched), or lowercasing
		// changed the length: the opening of the text, whole runes.
		end := min(len(text), 160)
		for end < len(text) && text[end]&0xC0 == 0x80 {
			end++
		}
		if end < len(text) {
			return text[:end] + "…"
		}
		return text
	}
	start := max(0, at-60)
	for start > 0 && text[start]&0xC0 == 0x80 {
		start--
	}
	end := min(len(text), at+len(w)+100)
	for end < len(text) && text[end]&0xC0 == 0x80 {
		end++
	}
	s := text[start:at] + "⟦" + text[at:at+len(w)] + "⟧" + text[at+len(w):end]
	if start > 0 {
		s = "…" + s
	}
	if end < len(text) {
		s += "…"
	}
	return s
}

// withLocal puts a repository's local ticket hits around its index
// hits — titled first, body after — once each, within limit.
func withLocal(results, titled, body []index.SearchResult, limit int) []index.SearchResult {
	if len(titled) == 0 && len(body) == 0 {
		return results
	}
	seen := map[string]bool{}
	var out []index.SearchResult
	for _, list := range [][]index.SearchResult{titled, results, body} {
		for _, r := range list {
			key := r.Kind + ":" + r.Ref
			if seen[key] {
				continue
			}
			seen[key] = true
			out = append(out, r)
		}
	}
	if limit > 0 && len(out) > limit {
		out = out[:limit]
	}
	return out
}