336 lines
9.8 KiB
Go
336 lines
9.8 KiB
Go
package search
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
)
|
|
|
|
type MeiliSearchEngine struct {
|
|
client *resty.Client
|
|
indexPrefix string
|
|
}
|
|
|
|
type meiliSearchResponse struct {
|
|
Hits []map[string]interface{} `json:"hits"`
|
|
EstimatedTotalHits int64 `json:"estimatedTotalHits"`
|
|
TotalHits int64 `json:"totalHits"`
|
|
}
|
|
|
|
func NewMeiliSearchEngine(cfg EngineConfig) *MeiliSearchEngine {
|
|
cfg = normalizeEngineConfig(cfg)
|
|
client := resty.New().SetBaseURL(strings.TrimRight(cfg.Host, "/"))
|
|
if cfg.APIKey != "" {
|
|
client.SetAuthToken(cfg.APIKey)
|
|
}
|
|
if cfg.TimeoutSeconds > 0 {
|
|
client.SetTimeout(time.Duration(cfg.TimeoutSeconds) * time.Second)
|
|
}
|
|
return &MeiliSearchEngine{client: client, indexPrefix: cfg.IndexPrefix}
|
|
}
|
|
|
|
func (e *MeiliSearchEngine) Search(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error) {
|
|
if filter == nil {
|
|
filter = &SearchFilter{Page: 1, PerPage: DefaultPerPage, SortBy: DefaultSortBy, SortOrder: DefaultSortOrder}
|
|
}
|
|
if filter.Page < 1 {
|
|
filter.Page = 1
|
|
}
|
|
if filter.PerPage < 1 {
|
|
filter.PerPage = DefaultPerPage
|
|
}
|
|
|
|
results := make([]SearchResult, 0)
|
|
byType := map[string]int64{}
|
|
var total int64
|
|
for _, docType := range searchableTypes(filter) {
|
|
resp, err := e.searchIndex(ctx, docType, accountID, query, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
count := resp.EstimatedTotalHits
|
|
if count == 0 && resp.TotalHits > 0 {
|
|
count = resp.TotalHits
|
|
}
|
|
byType[string(docType)] = count
|
|
total += count
|
|
for _, hit := range resp.Hits {
|
|
results = append(results, hitToSearchResult(docType, hit))
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(results, func(i, j int) bool {
|
|
if results[i].Score == results[j].Score {
|
|
return results[i].ID > results[j].ID
|
|
}
|
|
return results[i].Score > results[j].Score
|
|
})
|
|
|
|
return &SearchResponse{
|
|
Results: results,
|
|
TotalCount: total,
|
|
ByType: byType,
|
|
Page: filter.Page,
|
|
PerPage: filter.PerPage,
|
|
Query: strings.TrimSpace(query),
|
|
}, nil
|
|
}
|
|
|
|
func (e *MeiliSearchEngine) IndexDocument(ctx context.Context, doc SearchDocument) error {
|
|
doc.ensureUID()
|
|
resp, err := e.client.R().
|
|
SetContext(ctx).
|
|
SetBody([]SearchDocument{doc}).
|
|
Post(fmt.Sprintf("/indexes/%s/documents", e.indexName(doc.Type)))
|
|
return meiliError(resp, err, "index document")
|
|
}
|
|
|
|
func (e *MeiliSearchEngine) IndexBatch(ctx context.Context, docs []SearchDocument) error {
|
|
grouped := map[SearchResultType][]SearchDocument{}
|
|
for _, doc := range docs {
|
|
doc.ensureUID()
|
|
grouped[doc.Type] = append(grouped[doc.Type], doc)
|
|
}
|
|
for docType, batch := range grouped {
|
|
resp, err := e.client.R().
|
|
SetContext(ctx).
|
|
SetBody(batch).
|
|
Post(fmt.Sprintf("/indexes/%s/documents", e.indexName(docType)))
|
|
if err := meiliError(resp, err, "index batch"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *MeiliSearchEngine) DeleteDocument(ctx context.Context, docType SearchResultType, accountID uint, id uint) error {
|
|
resp, err := e.client.R().
|
|
SetContext(ctx).
|
|
Delete(fmt.Sprintf("/indexes/%s/documents/%s", e.indexName(docType), documentUID(docType, accountID, id)))
|
|
return meiliError(resp, err, "delete document")
|
|
}
|
|
|
|
func (e *MeiliSearchEngine) Bootstrap(ctx context.Context) error {
|
|
for _, docType := range searchableTypes(nil) {
|
|
if err := e.ensureIndex(ctx, docType); err != nil {
|
|
return err
|
|
}
|
|
if err := e.applySettings(ctx, docType); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *MeiliSearchEngine) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func (e *MeiliSearchEngine) searchIndex(ctx context.Context, docType SearchResultType, accountID uint, query string, filter *SearchFilter) (*meiliSearchResponse, error) {
|
|
body := map[string]interface{}{
|
|
"q": strings.TrimSpace(query),
|
|
"offset": filter.Offset(),
|
|
"limit": filter.PerPage,
|
|
"filter": e.filterExpression(accountID, docType, filter),
|
|
"showRankingScore": true,
|
|
}
|
|
if sortExpr := sortExpression(filter); sortExpr != "" {
|
|
body["sort"] = []string{sortExpr}
|
|
}
|
|
|
|
var out meiliSearchResponse
|
|
resp, err := e.client.R().
|
|
SetContext(ctx).
|
|
SetBody(body).
|
|
SetResult(&out).
|
|
Post(fmt.Sprintf("/indexes/%s/search", e.indexName(docType)))
|
|
if err := meiliError(resp, err, "search"); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (e *MeiliSearchEngine) ensureIndex(ctx context.Context, docType SearchResultType) error {
|
|
uid := e.indexName(docType)
|
|
resp, err := e.client.R().SetContext(ctx).Get(fmt.Sprintf("/indexes/%s", uid))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode() != http.StatusNotFound {
|
|
return meiliError(resp, nil, "check index")
|
|
}
|
|
resp, err = e.client.R().
|
|
SetContext(ctx).
|
|
SetBody(map[string]interface{}{"uid": uid, "primaryKey": "uid"}).
|
|
Post("/indexes")
|
|
return meiliError(resp, err, "create index")
|
|
}
|
|
|
|
func (e *MeiliSearchEngine) applySettings(ctx context.Context, docType SearchResultType) error {
|
|
settings := map[string]interface{}{
|
|
"searchableAttributes": []string{"title", "content", "snippet", "status", "priority", "labels", "locale"},
|
|
"filterableAttributes": []string{"account_id", "type", "status", "priority", "message_type", "sender_type", "sender_id", "content_type", "private", "contact_source", "labels", "assignee_id", "team_id", "inbox_id", "contact_id", "conversation_id", "portal_id", "locale", "created_at_ts", "updated_at_ts"},
|
|
"sortableAttributes": []string{"created_at_ts", "updated_at_ts", "id"},
|
|
}
|
|
resp, err := e.client.R().
|
|
SetContext(ctx).
|
|
SetBody(settings).
|
|
Patch(fmt.Sprintf("/indexes/%s/settings", e.indexName(docType)))
|
|
return meiliError(resp, err, "apply settings")
|
|
}
|
|
|
|
func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResultType, filter *SearchFilter) string {
|
|
parts := []string{fmt.Sprintf("account_id = %d", accountID)}
|
|
if filter == nil {
|
|
return strings.Join(parts, " AND ")
|
|
}
|
|
if docType == ResultTypeConversation {
|
|
parts = appendListFilter(parts, "status", filter.Status)
|
|
parts = appendListFilter(parts, "priority", filter.Priority)
|
|
if filter.AssigneeID != nil {
|
|
parts = append(parts, fmt.Sprintf("assignee_id = %d", *filter.AssigneeID))
|
|
}
|
|
if filter.TeamID != nil {
|
|
parts = append(parts, fmt.Sprintf("team_id = %d", *filter.TeamID))
|
|
}
|
|
parts = appendListFilter(parts, "labels", filter.Labels)
|
|
}
|
|
if filter.InboxID != nil {
|
|
parts = append(parts, fmt.Sprintf("inbox_id = %d", *filter.InboxID))
|
|
}
|
|
if docType == ResultTypeMessage {
|
|
if filter.MessageType != "" {
|
|
parts = append(parts, fmt.Sprintf("message_type = %q", filter.MessageType))
|
|
}
|
|
if filter.SenderType != "" {
|
|
parts = append(parts, fmt.Sprintf("sender_type = %q", filter.SenderType))
|
|
}
|
|
if filter.SenderID != nil {
|
|
parts = append(parts, fmt.Sprintf("sender_id = %d", *filter.SenderID))
|
|
}
|
|
if filter.ContentType != "" {
|
|
parts = append(parts, fmt.Sprintf("content_type = %q", filter.ContentType))
|
|
}
|
|
if filter.Private != nil {
|
|
parts = append(parts, fmt.Sprintf("private = %t", *filter.Private))
|
|
}
|
|
}
|
|
if docType == ResultTypeContact && filter.ContactSource != "" {
|
|
parts = append(parts, fmt.Sprintf("contact_source = %q", filter.ContactSource))
|
|
}
|
|
if docType == ResultTypeArticle || docType == ResultTypeHelpCenter {
|
|
if filter.PortalID != nil {
|
|
parts = append(parts, fmt.Sprintf("portal_id = %d", *filter.PortalID))
|
|
}
|
|
if filter.ArticleStatus != "" {
|
|
parts = append(parts, fmt.Sprintf("status = %q", filter.ArticleStatus))
|
|
}
|
|
if filter.ArticleLocale != "" {
|
|
parts = append(parts, fmt.Sprintf("locale = %q", filter.ArticleLocale))
|
|
}
|
|
}
|
|
if filter.DateFrom != nil {
|
|
parts = append(parts, fmt.Sprintf("created_at_ts >= %d", filter.DateFrom.Unix()))
|
|
}
|
|
if filter.DateTo != nil {
|
|
parts = append(parts, fmt.Sprintf("created_at_ts <= %d", filter.DateTo.Unix()))
|
|
}
|
|
return strings.Join(parts, " AND ")
|
|
}
|
|
|
|
func (e *MeiliSearchEngine) indexName(docType SearchResultType) string {
|
|
return e.indexPrefix + strings.ReplaceAll(string(docType), "_", "_") + "s"
|
|
}
|
|
|
|
func appendListFilter(parts []string, field string, values []string) []string {
|
|
if len(values) == 0 {
|
|
return parts
|
|
}
|
|
quoted := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
quoted = append(quoted, fmt.Sprintf("%s = %q", field, value))
|
|
}
|
|
}
|
|
if len(quoted) > 0 {
|
|
parts = append(parts, "("+strings.Join(quoted, " OR ")+")")
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func sortExpression(filter *SearchFilter) string {
|
|
if filter == nil {
|
|
return ""
|
|
}
|
|
field := filter.SortBy
|
|
switch field {
|
|
case "", "created", "created_at":
|
|
field = "created_at_ts"
|
|
case "updated", "updated_at":
|
|
field = "updated_at_ts"
|
|
case "id":
|
|
field = "id"
|
|
default:
|
|
return ""
|
|
}
|
|
dir := filter.SortOrder
|
|
if dir != "asc" {
|
|
dir = "desc"
|
|
}
|
|
return field + ":" + dir
|
|
}
|
|
|
|
func hitToSearchResult(docType SearchResultType, hit map[string]interface{}) SearchResult {
|
|
result := SearchResult{Type: docType, Data: hit}
|
|
if v, ok := hit["type"].(string); ok && v != "" {
|
|
result.Type = SearchResultType(v)
|
|
}
|
|
result.ID = uintFromHit(hit["id"])
|
|
result.AccountID = uintFromHit(hit["account_id"])
|
|
if snippet, ok := hit["snippet"].(string); ok {
|
|
result.Snippet = snippet
|
|
} else if title, ok := hit["title"].(string); ok {
|
|
result.Snippet = title
|
|
}
|
|
if score, ok := hit["_rankingScore"].(float64); ok {
|
|
result.Score = score
|
|
} else {
|
|
result.Score = 1
|
|
}
|
|
return result
|
|
}
|
|
|
|
func uintFromHit(v interface{}) uint {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return uint(n)
|
|
case int:
|
|
return uint(n)
|
|
case int64:
|
|
return uint(n)
|
|
case uint:
|
|
return n
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func meiliError(resp *resty.Response, err error, action string) error {
|
|
if err != nil {
|
|
return fmt.Errorf("meilisearch %s: %w", action, err)
|
|
}
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
if resp.StatusCode() >= 400 {
|
|
return fmt.Errorf("meilisearch %s: status=%d body=%s", action, resp.StatusCode(), string(resp.Body()))
|
|
}
|
|
return nil
|
|
}
|