- jz_bm_material_file_texts caches the pdftotext output per file - the River job jz.bm.extract_material_text extracts and resyncs - the management multipart attach queues it inside its transaction - file_texts in the document and a file snippet with its name
185 lines
5.6 KiB
Go
185 lines
5.6 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"
|
|
"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
|
|
}
|