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
}