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
This commit is contained in:
Jakub Zych
2026-11-08 19:45:46 +01:00
parent f7c400756f
commit 4ffce6e6ac
18 changed files with 1315 additions and 36 deletions

View File

@@ -6,9 +6,11 @@ import (
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/beachcomber"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/jakub/sm-bm-plugin/classes"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
@@ -49,23 +51,35 @@ func preloadMaterial(q *gorm.DB) *gorm.DB {
}
// listMaterials returns one page of the published materials userID may
// see, newest first.
func listMaterials(db *gorm.DB, now time.Time, userID uint, f materialFilter, page, perPage int) ([]materialListJSON, int64, error) {
// see. With ids nil the page is newest first. With ids non-nil (search
// candidates in the engine's rank order) both the count and the page are
// also limited to ids and the page follows their order; the ids never widen
// what visibleMaterials and the filter allow. An empty ids is an empty page.
func listMaterials(db *gorm.DB, now time.Time, userID uint, f materialFilter, ids []uint, page, perPage int) ([]materialListJSON, []models.Material, int64, error) {
if ids != nil && len(ids) == 0 {
return []materialListJSON{}, nil, 0, nil
}
q := f.apply(visibleMaterials(db.Model(&models.Material{}), now, userID))
if ids != nil {
q = q.Where("jz_bm_materials.id IN ?", ids)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
return nil, nil, 0, err
}
rowsQ := preloadMaterial(f.apply(visibleMaterials(db, now, userID)))
if ids != nil {
rowsQ = rowsQ.Where("jz_bm_materials.id IN ?", ids).Order(rankOrder(ids))
} else {
rowsQ = rowsQ.Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC")
}
var rows []models.Material
err := preloadMaterial(f.apply(visibleMaterials(db, now, userID))).
Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC").
Offset((page - 1) * perPage).Limit(perPage).
Find(&rows).Error
err := rowsQ.Offset((page - 1) * perPage).Limit(perPage).Find(&rows).Error
if err != nil {
return nil, 0, err
return nil, nil, 0, err
}
out, err := materialsJSON(db, rows)
return out, total, err
return out, rows, total, err
}
// materialsJSON builds list rows with their file counts.
@@ -99,31 +113,69 @@ func materialsJSON(db *gorm.DB, rows []models.Material) ([]materialListJSON, err
return out, nil
}
// Materials answers GET /materials?course=&meeting=&tag=&type=&page=&per_page=:
// Materials answers GET /materials?q=&course=&meeting=&tag=&type=&page=&per_page=:
// a page of the published materials the caller may see, in the {data, meta}
// shape of lagoon.Paginate.
// shape of lagoon.Paginate. With q (2 to 100 characters) the engine answers
// candidates, which SQL re-gates, counts and pages in rank order, each row
// with at most one snippet; when the engine cannot answer the request is a
// 503. Without q the list is newest first and never calls the engine.
func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
in := query(r, "course", "meeting", "tag", "type", "page", "per_page")
in := query(r, "q", "course", "meeting", "tag", "type", "page", "per_page")
if !h.validate(w, r, in, materialsRules) {
return
}
filter := materialFilter{CourseID: uintParam(in, "course"), MeetingID: uintParam(in, "meeting"), Tag: strings.TrimSpace(str(in, "tag")), Type: str(in, "type")}
var cands *classes.MaterialCandidates
if q := searchQuery(in); q != "" {
c, err := h.MaterialIndex.Search(r.Context(), classes.MaterialSearch{Q: q, CourseID: filter.CourseID, MeetingID: filter.MeetingID, Type: filter.Type, Tag: filter.Tag})
if err != nil {
writeSearchUnavailable(w)
return
}
cands = &c
}
db, ok := h.db(w, r)
if !ok {
return
}
filter := materialFilter{CourseID: uintParam(in, "course"), MeetingID: uintParam(in, "meeting"), Tag: strings.TrimSpace(str(in, "tag")), Type: str(in, "type")}
page := min(intParam(in, "page", 1), 100000)
perPage := intParam(in, "per_page", h.perPage())
rows, total, err := listMaterials(db, h.now(), userID, filter, page, perPage)
var ids []uint
if cands != nil {
ids = cands.IDs
if ids == nil {
ids = []uint{}
}
}
out, rows, total, err := listMaterials(db, h.now(), userID, filter, ids, page, perPage)
if err != nil {
writeOpaque500(w)
return
}
writeJSONPage(w, lagoon.Paginate(rows, page, perPage, total))
if cands != nil {
attachSnippets(out, rows, cands.Hits, viaMCP(r))
}
writeJSONPage(w, lagoon.Paginate(out, page, perPage, total))
}
// attachSnippets sets the snippet of each list row from its Hit. rows are
// the rows SQL returned (they passed the re-gate), in the order of out; the
// Hit of an id SQL did not return is never read.
func attachSnippets(out []materialListJSON, rows []models.Material, hits map[uint]beachcomber.Hit, mcp bool) {
for i := range out {
if i >= len(rows) || rows[i].ID != out[i].ID {
continue
}
hit, ok := hits[rows[i].ID]
if !ok {
continue
}
out[i].Snippet = snippetValue(pickSnippet(hit, rows[i]), mcp)
}
}
// Material answers GET /materials/{id}: one published material the caller