search.go
// Package search provides full-text search across all website CMS content
// using SQLite FTS5. It maintains a unified search index that covers pages,
// collections, documents, files, partials, notes, and users.
package search
import (
"fmt"
"log"
"strings"
"github.com/readysite/readysite/website/models"
)
// SearchResult holds a single search hit.
type SearchResult struct {
EntityID string `json:"id"`
EntityType string `json:"type"`
CollectionID string `json:"collection_id,omitempty"`
Title string `json:"title"`
Snippet string `json:"snippet"`
Rank float64 `json:"rank"`
}
// SearchOptions controls search behavior.
type SearchOptions struct {
Query string
EntityType string // Filter by type (optional)
CollectionID string // Filter documents by collection (optional)
Page int
PerPage int // Default 20, max 100
}
const createTableSQL = `CREATE VIRTUAL TABLE IF NOT EXISTS SearchIndex USING fts5(
title,
body,
tags,
entity_id UNINDEXED,
entity_type UNINDEXED,
collection_id UNINDEXED,
tokenize='porter unicode61 remove_diacritics 2'
)`
// Init creates the FTS5 virtual table if it doesn't exist.
func Init() {
if _, err := models.DB.Exec(createTableSQL); err != nil {
log.Printf("[search] Failed to create FTS5 table: %v", err)
return
}
// Build index on first run
if !isIndexBuilt() {
log.Printf("[search] Building initial search index...")
IndexAll()
markIndexBuilt()
log.Printf("[search] Search index built successfully")
}
}
// Search performs full-text search across all indexed content.
func Search(opts SearchOptions) ([]SearchResult, int, error) {
if opts.Query == "" {
return nil, 0, nil
}
if opts.PerPage <= 0 {
opts.PerPage = 20
}
if opts.PerPage > 100 {
opts.PerPage = 100
}
if opts.Page <= 0 {
opts.Page = 1
}
// Build the FTS5 MATCH query
matchQuery := sanitizeFTS(opts.Query)
if matchQuery == "" {
return nil, 0, nil
}
// Build WHERE conditions for filtering
var conditions []string
var args []any
conditions = append(conditions, "SearchIndex MATCH ?")
args = append(args, matchQuery)
if opts.EntityType != "" {
conditions = append(conditions, "entity_type = ?")
args = append(args, opts.EntityType)
}
if opts.CollectionID != "" {
conditions = append(conditions, "collection_id = ?")
args = append(args, opts.CollectionID)
}
where := strings.Join(conditions, " AND ")
// Count total
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM SearchIndex WHERE %s", where)
var total int
if err := models.DB.QueryRow(countQuery, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("search count: %w", err)
}
if total == 0 {
return nil, 0, nil
}
// Fetch results with BM25 ranking and snippets
offset := (opts.Page - 1) * opts.PerPage
selectQuery := fmt.Sprintf(`
SELECT entity_id, entity_type, collection_id, title,
snippet(SearchIndex, 1, '<mark>', '</mark>', '...', 40),
bm25(SearchIndex, 10.0, 1.0, 5.0)
FROM SearchIndex
WHERE %s
ORDER BY bm25(SearchIndex, 10.0, 1.0, 5.0)
LIMIT ? OFFSET ?
`, where)
args = append(args, opts.PerPage, offset)
rows, err := models.DB.Query(selectQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("search query: %w", err)
}
defer rows.Close()
var results []SearchResult
for rows.Next() {
var r SearchResult
if err := rows.Scan(&r.EntityID, &r.EntityType, &r.CollectionID, &r.Title, &r.Snippet, &r.Rank); err != nil {
return nil, 0, fmt.Errorf("search scan: %w", err)
}
results = append(results, r)
}
return results, total, rows.Err()
}
// sanitizeFTS prepares a user query for FTS5 MATCH.
// It quotes each term to prevent FTS5 syntax errors from user input.
func sanitizeFTS(query string) string {
// Split into words and quote each one for safe FTS5 usage
words := strings.Fields(query)
if len(words) == 0 {
return ""
}
var quoted []string
for _, w := range words {
// Remove any existing quotes and FTS5 operators
w = strings.ReplaceAll(w, "\"", "")
w = strings.TrimSpace(w)
if w == "" || w == "AND" || w == "OR" || w == "NOT" || w == "NEAR" {
continue
}
quoted = append(quoted, "\""+w+"\"")
}
if len(quoted) == 0 {
return ""
}
return strings.Join(quoted, " ")
}