feat: relevance-ordered search, suggest rows and journal:reindex
- 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.
This commit is contained in:
@@ -2,14 +2,19 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ beachcomber.Searchable = (*Post)(nil)
|
||||
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" }
|
||||
@@ -75,6 +80,54 @@ func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]a
|
||||
"tag_ids": tagIDs,
|
||||
"title": p.Title,
|
||||
"excerpt": p.Excerpt,
|
||||
"content": p.Content,
|
||||
"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())), " ")
|
||||
}
|
||||
|
||||
59
models/post_search_test.go
Normal file
59
models/post_search_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSearchDocumentIsPlainText(t *testing.T) {
|
||||
past := time.Now().Add(-time.Hour)
|
||||
p := &Post{
|
||||
ID: 7,
|
||||
Title: "Kurki",
|
||||
Excerpt: "Zajawka",
|
||||
Content: "## Kurki",
|
||||
ContentHTML: `<h2 class="x">Kurki</h2><p>rosną w <a href="/las">lesie</a> & pachną</p>`,
|
||||
Published: true,
|
||||
PublishedAt: &past,
|
||||
}
|
||||
doc, err := p.ToSearchableArray(t.Context(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, _ := doc["content"].(string)
|
||||
if strings.ContainsAny(content, "<>") || strings.Contains(content, "href") || strings.Contains(content, "&") {
|
||||
t.Fatalf("content keeps markup: %q", content)
|
||||
}
|
||||
if content != "Kurki rosną w lesie & pachną" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
|
||||
p.ContentHTML = ""
|
||||
doc, _ = p.ToSearchableArray(t.Context(), nil)
|
||||
if doc["content"] != "## Kurki" {
|
||||
t.Fatalf("content without content_html = %q", doc["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchSchemaMatchesDocument: the schema declares exactly the keys
|
||||
// ToSearchableArray emits.
|
||||
func TestSearchSchemaMatchesDocument(t *testing.T) {
|
||||
doc, err := (&Post{ID: 1}).ToSearchableArray(t.Context(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var docKeys, schemaKeys []string
|
||||
for k := range doc {
|
||||
docKeys = append(docKeys, k)
|
||||
}
|
||||
for _, f := range (Post{}).SearchIndexSchema()["fields"].([]map[string]any) {
|
||||
schemaKeys = append(schemaKeys, f["name"].(string))
|
||||
}
|
||||
sort.Strings(docKeys)
|
||||
sort.Strings(schemaKeys)
|
||||
if strings.Join(docKeys, ",") != strings.Join(schemaKeys, ",") {
|
||||
t.Fatalf("document keys %v, schema fields %v", docKeys, schemaKeys)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user