package api import ( "net/http" "strconv" "strings" "git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/beachcomber" "git.golem15.com/golem15/summercms/modules/beachcomber/typesense" "gorm.io/gorm" ) // searchWindow is how many engine candidates one search re-gates in SQL. // The list pages over the gated rows, so a page never depends on how the // engine paged. const searchWindow = typesense.MaxPerPage // searchWithEngine returns the candidate post ids for search in the engine's // rank order, and whether the engine answered. It asks for the whole result // window (the first searchWindow hits), not one page: the caller re-gates // the ids in SQL, counts them and pages over them. When it reports false the // caller falls back to SQL search. func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search string, publishedOnly bool) ([]uint, bool) { if app == nil || db == nil { return nil, false } svc, err := beachcomber.From(app) if err != nil || svc == nil || svc.Engine() == nil || !svc.Engine().Configured() { return nil, false } var settings models.Settings if err := db.First(&settings, 1).Error; err != nil { if !models.SearchGateEnabled() { return nil, false } settings.SearchTitleWeight = 5 settings.SearchExcerptWeight = 3 settings.SearchContentWeight = 1 } else { // The row is the gate: the in-memory one is set only once a sync // has run since boot. models.SetSearchGate(settings.SearchUseTypesense) if !settings.SearchUseTypesense { return nil, false } } q := beachcomber.Query{ Q: search, QueryBy: []string{"title", "excerpt", "content"}, QueryByWeights: []int{settings.SearchTitleWeight, settings.SearchExcerptWeight, settings.SearchContentWeight}, SortBy: "_text_match:desc,published_at:desc", Page: 1, PerPage: searchWindow, } if publishedOnly { q.FilterBy = "published:=1" } res, err := beachcomber.SearchPage(r.Context(), svc.Engine(), svc.IndexName(&models.Post{}), q) if err != nil { pluginLog.Warn("journal: Typesense search failed, falling back to DB search", "err", err) return nil, false } if res.Found > searchWindow { pluginLog.Debug("journal: search matched more posts than one window; keeping the first", "found", res.Found, "kept", searchWindow) } ids := make([]uint, 0, len(res.IDs)) for _, raw := range res.IDs { n, err := strconv.ParseUint(raw, 10, 64) if err != nil || n == 0 { continue } ids = append(ids, uint(n)) } return ids, true } // rankOrder orders rows by their position in ids, the engine's rank, with // pinned posts first when pinnedFirst. The ids are parsed integers, so the // array literal carries no caller text. func rankOrder(ids []uint, pinnedFirst bool) string { parts := make([]string, len(ids)) for i, id := range ids { parts[i] = strconv.FormatUint(uint64(id), 10) } order := "array_position(ARRAY[" + strings.Join(parts, ",") + "]::bigint[], id), id DESC" if pinnedFirst { return "is_pinned DESC, " + order } return order }