Files
sm-bm-plugin/controllers/api/material_search.go
Jakub Zych 4ffce6e6ac feat: search materials through beachcomber
- Material is a beachcomber Searchable (index jz_bm_materials) in the
  hand-written models/material_search.go; jz.bm requires summercms v0.6.4
- classes.MaterialIndexer asks Typesense for up to 250 candidates with
  body highlights; the app-wide Gate is on only for jz.bm-marked contexts
- GET /_bm/api/v1/materials takes q: candidates are re-gated, counted and
  paged in SQL in rank order, each row with at most one plain snippet
- search failure answers 503 Search is unavailable.; without q the list
  is unchanged
2026-11-08 19:45:46 +01:00

124 lines
3.8 KiB
Go

package api
import (
"net/http"
"strconv"
"strings"
"unicode/utf8"
"git.golem15.com/golem15/summercms/modules/beachcomber"
"git.golem15.com/golem15/summercms/modules/wire"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
// Material search: the engine (beachcomber, Typesense) answers candidate ids
// only. Visibility, the filters, the total and the paging stay in SQL, and a
// row's snippet is read only once SQL returned that row, so a material the
// caller may not see never reaches the JSON, its count or a snippet.
// minSearchRunes is the shortest query that searches; a shorter one is
// ignored, as the student app does.
const minSearchRunes = 2
// searchQuery returns the trimmed q of in, or "" when it is shorter than
// minSearchRunes.
func searchQuery(in map[string]any) string {
q := strings.TrimSpace(str(in, "q"))
if utf8.RuneCountInString(q) < minSearchRunes {
return ""
}
return q
}
// writeSearchUnavailable answers a search the engine cannot serve: it is not
// configured or the call failed. The list without q keeps working.
func writeSearchUnavailable(w http.ResponseWriter) {
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": true, "message": "Search is unavailable."})
}
// rankOrder orders rows by their position in ids, the engine's rank. The ids
// are parsed integers, so the array literal carries no caller text.
func rankOrder(ids []uint) string {
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.FormatUint(uint64(id), 10)
}
return "array_position(ARRAY[" + strings.Join(parts, ",") + "]::bigint[], jz_bm_materials.id), jz_bm_materials.id DESC"
}
// segmentJSON is one run of a snippet: the engine's plain text, verbatim,
// and whether it matched the query. It is never markup; the client renders
// it as text.
type segmentJSON struct {
Text string `json:"text"`
Hit bool `json:"hit"`
}
// snippetJSON is the one excerpt a search result shows. Source is "body" or
// "file" (FileName set); Lead and Trail say whether text was cut before or
// after the excerpt.
type snippetJSON struct {
Source string `json:"source"`
FileName *string `json:"file_name"`
Lead bool `json:"lead"`
Trail bool `json:"trail"`
Segments []segmentJSON `json:"segments"`
}
// pickSnippet returns the snippet of m from hit: the first body snippet,
// mapped one to one. It is nil when the hit has none. Call it only for a
// row the SQL re-gate returned.
func pickSnippet(hit beachcomber.Hit, m models.Material) *snippetJSON {
for _, sn := range hit.Highlights {
if sn.Field != "body" || len(sn.Segments) == 0 {
continue
}
out := &snippetJSON{Source: "body", Segments: make([]segmentJSON, 0, len(sn.Segments))}
var joined strings.Builder
for _, seg := range sn.Segments {
out.Segments = append(out.Segments, segmentJSON{Text: seg.Text, Hit: seg.Match})
joined.WriteString(seg.Text)
}
body := ""
if m.Body != nil {
body = models.MaterialPlainBody(*m.Body)
}
text := strings.TrimSpace(joined.String())
out.Lead = !strings.HasPrefix(body, text)
out.Trail = !strings.HasSuffix(body, text)
return out
}
return nil
}
// flattenSnippet is a snippet as plain text, for MCP: the segment texts
// joined, with an ellipsis where text was cut.
func flattenSnippet(s *snippetJSON) string {
if s == nil {
return ""
}
var b strings.Builder
if s.Lead {
b.WriteString("…")
}
for _, seg := range s.Segments {
b.WriteString(seg.Text)
}
if s.Trail {
b.WriteString("…")
}
return b.String()
}
// snippetValue is the snippet key of a row: the struct, a plain string
// through MCP, or nil when there is none (the key is then left out).
func snippetValue(s *snippetJSON, mcp bool) any {
if s == nil {
return nil
}
if mcp {
return flattenSnippet(s)
}
return s
}