readysite / website / internal / search / search.go
4.2 KB
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, " ")
}
← Back