verstak/internal/core/search/search.go

99 lines
2.5 KiB
Go

package search
import (
"strings"
"verstak/internal/core/storage"
)
// Result is a single search hit.
type Result struct {
NodeID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Snippet string `json:"snippet,omitempty"`
}
// Service manages FTS5 search index.
type Service struct {
db *storage.DB
}
// NewService creates a search service.
func NewService(db *storage.DB) *Service {
return &Service{db: db}
}
// Index adds or updates a document in the FTS5 index.
func (s *Service) Index(nodeID, title, content, path, tags, docType string) error {
// Delete old entry first (FTS5 doesn't support UPDATE).
s.db.Exec("DELETE FROM search_index WHERE node_id=?", nodeID)
_, err := s.db.Exec(
`INSERT INTO search_index (node_id,title,content,path,tags,type)
VALUES (?,?,?,?,?,?)`,
nodeID, title, content, path, tags, docType,
)
return err
}
// Remove deletes a document from the index.
func (s *Service) Remove(nodeID string) error {
_, err := s.db.Exec("DELETE FROM search_index WHERE node_id=?", nodeID)
return err
}
// Rebuild clears and rebuilds the entire index.
// Creates the FTS5 table if it doesn't exist (requires FTS5 support).
func (s *Service) Rebuild() error {
_, _ = s.db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
node_id UNINDEXED, title, content, path, tags, type)`)
_, err := s.db.Exec("DELETE FROM search_index")
return err
}
// Search queries the FTS5 index. Returns up to 20 results.
func (s *Service) Search(query string) ([]Result, error) {
if strings.TrimSpace(query) == "" {
return nil, nil
}
// Escape FTS5 special characters.
fts := sanitizeFTS(query)
rows, err := s.db.Query(
`SELECT node_id, title, type, snippet(search_index, 0, '', '', '...', 32) as snip
FROM search_index WHERE search_index MATCH ?
ORDER BY rank LIMIT 20`, fts)
if err != nil {
// FTS5 table may not exist (no FTS5 support or not rebuilt yet).
return nil, nil
}
defer rows.Close()
var out []Result
for rows.Next() {
var r Result
var snip sqlNullString
if err := rows.Scan(&r.NodeID, &r.Title, &r.Type, &snip); err != nil {
return nil, err
}
if snip.Valid {
r.Snippet = snip.String
}
out = append(out, r)
}
return out, rows.Err()
}
func sanitizeFTS(q string) string {
// Wrap in double quotes for phrase search, escape inner quotes.
q = strings.TrimSpace(q)
q = strings.ReplaceAll(q, `"`, `""`)
return `"` + q + `"`
}
type sqlNullString = struct {
String string
Valid bool
}