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())), " ") }