99 lines
2.5 KiB
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
|
|
}
|