- Engine search asks Typesense for the first 250 candidates, re-gates them in SQL, counts the gated rows and pages over them in rank order; pages after the first are no longer empty. An explicit sort overrides the rank. - The settings row gates engine search, so it works straight after boot. - ?fields=suggest serves id, title, slug, a plain-text excerpt, featured_image_url and published_at. - Content is indexed as plain text; Post declares SearchIndexSchema. - journal:reindex [--fresh] syncs every post into the index.
134 lines
3.5 KiB
Go
134 lines
3.5 KiB
Go
package models
|
|
|
|
import (
|
|
"context"
|
|
"html"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var (
|
|
_ beachcomber.Searchable = (*Post)(nil)
|
|
_ beachcomber.IndexSchemaProvider = (*Post)(nil)
|
|
)
|
|
|
|
// SearchableAs is the Typesense collection name (D-12).
|
|
func (Post) SearchableAs() string { return "golem15_journal_posts" }
|
|
|
|
// ShouldBeSearchable is false when the post is unpublished or the
|
|
// beachcomber Gate (search_use_typesense) is off.
|
|
func (p *Post) ShouldBeSearchable() bool {
|
|
if p == nil || !p.Published || p.PublishedAt == nil || !p.PublishedAt.Before(time.Now()) {
|
|
return false
|
|
}
|
|
return SearchGateEnabled()
|
|
}
|
|
|
|
// ToSearchableArray builds the Typesense document after commit.
|
|
func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error) {
|
|
if p == nil {
|
|
return nil, nil
|
|
}
|
|
published := 0
|
|
if p.Published && p.PublishedAt != nil && p.PublishedAt.Before(time.Now()) {
|
|
published = 1
|
|
}
|
|
pinned := 0
|
|
if p.IsPinned {
|
|
pinned = 1
|
|
}
|
|
var publishedAt int64
|
|
if p.PublishedAt != nil {
|
|
publishedAt = p.PublishedAt.Unix()
|
|
}
|
|
var createdAt int64
|
|
if p.CreatedAt != nil {
|
|
createdAt = p.CreatedAt.Unix()
|
|
}
|
|
userID := 0
|
|
if p.UserID != nil {
|
|
userID = int(*p.UserID)
|
|
}
|
|
catIDs := make([]int, 0, len(p.Categories))
|
|
tagIDs := make([]int, 0, len(p.Tags))
|
|
if db != nil {
|
|
var cats []PostCategory
|
|
if err := db.WithContext(ctx).Where("post_id = ?", p.ID).Find(&cats).Error; err == nil {
|
|
for _, c := range cats {
|
|
catIDs = append(catIDs, int(c.CategoryID))
|
|
}
|
|
}
|
|
var tags []PostTag
|
|
if err := db.WithContext(ctx).Where("post_id = ?", p.ID).Find(&tags).Error; err == nil {
|
|
for _, t := range tags {
|
|
tagIDs = append(tagIDs, int(t.TagID))
|
|
}
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"id": strconv.FormatUint(uint64(p.ID), 10),
|
|
"published": published,
|
|
"published_at": publishedAt,
|
|
"is_pinned": pinned,
|
|
"user_id": userID,
|
|
"created_at": createdAt,
|
|
"category_ids": catIDs,
|
|
"tag_ids": tagIDs,
|
|
"title": p.Title,
|
|
"excerpt": p.Excerpt,
|
|
"content": PlainText(p.searchContent()),
|
|
}, nil
|
|
}
|
|
|
|
// searchContent is the post body to index: the rendered content_html, or
|
|
// content when nothing has been rendered.
|
|
func (p *Post) searchContent() string {
|
|
if strings.TrimSpace(p.ContentHTML) != "" {
|
|
return p.ContentHTML
|
|
}
|
|
return p.Content
|
|
}
|
|
|
|
// SearchIndexSchema is the Typesense collection schema. It applies only when
|
|
// the collection is created; journal:reindex --fresh recreates it.
|
|
func (Post) SearchIndexSchema() map[string]any {
|
|
return map[string]any{
|
|
"fields": []map[string]any{
|
|
{"name": "id", "type": "string"},
|
|
{"name": "title", "type": "string"},
|
|
{"name": "excerpt", "type": "string"},
|
|
{"name": "content", "type": "string"},
|
|
{"name": "published", "type": "int64"},
|
|
{"name": "published_at", "type": "int64"},
|
|
{"name": "is_pinned", "type": "int64"},
|
|
{"name": "user_id", "type": "int64"},
|
|
{"name": "created_at", "type": "int64"},
|
|
{"name": "category_ids", "type": "int64[]", "facet": false},
|
|
{"name": "tag_ids", "type": "int64[]", "facet": false},
|
|
},
|
|
}
|
|
}
|
|
|
|
// PlainText drops HTML tags and unescapes entities, with whitespace runs
|
|
// collapsed to one space. A tag counts as a word break.
|
|
func PlainText(s string) string {
|
|
var b strings.Builder
|
|
inTag := false
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '<':
|
|
inTag = true
|
|
b.WriteRune(' ')
|
|
case r == '>':
|
|
inTag = false
|
|
case !inTag:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return strings.Join(strings.Fields(html.UnescapeString(b.String())), " ")
|
|
}
|