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" "gorm.io/gorm" ) // 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, or else the first file_texts snippet, with the name of // the file at its array index in files (the IndexedFiles of m, in document // order; nil when the index does not resolve). A file text is long and is // not loaded for the page, so a file snippet is always cut on both sides. // It is nil when the hit has neither. Call it only for a row the SQL // re-gate returned. func pickSnippet(hit beachcomber.Hit, m models.Material, files []models.IndexedFile) *snippetJSON { for _, sn := range hit.Highlights { if sn.Field != "body" || len(sn.Segments) == 0 { continue } out := &snippetJSON{Source: "body", Segments: snippetSegments(sn)} var joined strings.Builder for _, seg := range sn.Segments { 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 } for _, sn := range hit.Highlights { if sn.Field != fileTextsField || len(sn.Segments) == 0 { continue } out := &snippetJSON{Source: "file", Lead: true, Trail: true, Segments: snippetSegments(sn)} if sn.ArrayIndex >= 0 && sn.ArrayIndex < len(files) { name := files[sn.ArrayIndex].FileName out.FileName = &name } return out } return nil } // fileTextsField is the document field of the attached files' texts. const fileTextsField = "file_texts" // snippetSegments maps the engine's segments one to one. func snippetSegments(sn beachcomber.Snippet) []segmentJSON { out := make([]segmentJSON, 0, len(sn.Segments)) for _, seg := range sn.Segments { out = append(out, segmentJSON{Text: seg.Text, Hit: seg.Match}) } return out } // hasFileSnippet reports whether hit carries a file_texts snippet and no // body one, so its snippet needs the material's file names. func hasFileSnippet(hit beachcomber.Hit) bool { file := false for _, sn := range hit.Highlights { if len(sn.Segments) == 0 { continue } switch sn.Field { case "body": return false case fileTextsField: file = true } } return file } // snippetFiles loads, once per page, the indexed files (names only) of the // rows whose snippet comes from a file. It reads nothing when no row needs // it. ids are rows SQL returned. func snippetFiles(db *gorm.DB, ids []uint, hits map[uint]beachcomber.Hit) (map[uint][]models.IndexedFile, error) { var need []uint for _, id := range ids { if hit, ok := hits[id]; ok && hasFileSnippet(hit) { need = append(need, id) } } if len(need) == 0 { return nil, nil } return models.IndexedFiles(db, need, false) } // 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 }