Enhance search functionality with multi-engine support and configuration updates
- Added support for Typesense as a search engine alongside Meilisearch and PostgreSQL. - Updated configuration structure to include Typesense parameters in `Config` and `.env.example`. - Enhanced search handler and service to accommodate external search clients and filters. - Implemented new tests for external search clients and search service functionalities. - Updated project checklist to reflect completion of multi-index search features and contextual snippets.
This commit is contained in:
parent
a2e17c5b6c
commit
0435e27ce6
11
.env.example
11
.env.example
@ -25,6 +25,7 @@ JITSI_APP_SECRET=changeme-jwt-secret
|
|||||||
JITSI_INTERNAL_AUTH_PASSWORD=changeme
|
JITSI_INTERNAL_AUTH_PASSWORD=changeme
|
||||||
KEYDB_PASSWORD=
|
KEYDB_PASSWORD=
|
||||||
MEILISEARCH_API_KEY=changeme
|
MEILISEARCH_API_KEY=changeme
|
||||||
|
TYPESENSE_API_KEY=changeme
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# General
|
# General
|
||||||
@ -193,8 +194,16 @@ MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Recherche
|
# Recherche
|
||||||
|
# SEARCH_ENGINE: postgres (defaut) | meilisearch | typesense
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
SEARCH_ENGINE=postgres
|
SEARCH_ENGINE=postgres
|
||||||
# SEARCH_ENGINE=meilisearch
|
|
||||||
|
# --- Meilisearch (SEARCH_ENGINE=meilisearch) ---
|
||||||
# MEILISEARCH_URL=http://meilisearch:7700
|
# MEILISEARCH_URL=http://meilisearch:7700
|
||||||
# MEILISEARCH_API_KEY={{MEILISEARCH_API_KEY}}
|
# MEILISEARCH_API_KEY={{MEILISEARCH_API_KEY}}
|
||||||
|
# MEILISEARCH_INDEX=ulti
|
||||||
|
|
||||||
|
# --- Typesense (SEARCH_ENGINE=typesense) ---
|
||||||
|
# TYPESENSE_URL=http://typesense:8108
|
||||||
|
# TYPESENSE_API_KEY={{TYPESENSE_API_KEY}}
|
||||||
|
# TYPESENSE_COLLECTION=ulti
|
||||||
|
|||||||
@ -195,7 +195,16 @@ func main() {
|
|||||||
|
|
||||||
r.Mount("/api/v1/mail", mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter).Routes())
|
r.Mount("/api/v1/mail", mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter).Routes())
|
||||||
r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes())
|
r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes())
|
||||||
r.Get("/api/v1/search", search.NewHandler(pool).Search)
|
r.Get("/api/v1/search", search.NewHandler(pool, search.Options{
|
||||||
|
Nextcloud: ncClient,
|
||||||
|
Engine: cfg.SearchEngine,
|
||||||
|
MeilisearchURL: cfg.MeilisearchURL,
|
||||||
|
MeilisearchKey: cfg.MeilisearchKey,
|
||||||
|
MeilisearchIndex: cfg.MeilisearchIndex,
|
||||||
|
TypesenseURL: cfg.TypesenseURL,
|
||||||
|
TypesenseKey: cfg.TypesenseKey,
|
||||||
|
TypesenseCollection: cfg.TypesenseCollection,
|
||||||
|
}).Search)
|
||||||
|
|
||||||
if ncClient != nil {
|
if ncClient != nil {
|
||||||
r.Mount("/api/v1/drive", drive.NewHandler(ncClient).Routes())
|
r.Mount("/api/v1/drive", drive.NewHandler(ncClient).Routes())
|
||||||
|
|||||||
@ -75,6 +75,10 @@ type Config struct {
|
|||||||
SearchEngine string
|
SearchEngine string
|
||||||
MeilisearchURL string
|
MeilisearchURL string
|
||||||
MeilisearchKey string
|
MeilisearchKey string
|
||||||
|
MeilisearchIndex string
|
||||||
|
TypesenseURL string
|
||||||
|
TypesenseKey string
|
||||||
|
TypesenseCollection string
|
||||||
|
|
||||||
// Observability
|
// Observability
|
||||||
HealthNextcloudURL string
|
HealthNextcloudURL string
|
||||||
@ -145,6 +149,10 @@ func Load() (*Config, error) {
|
|||||||
SearchEngine: envOrDefault("SEARCH_ENGINE", "postgres"),
|
SearchEngine: envOrDefault("SEARCH_ENGINE", "postgres"),
|
||||||
MeilisearchURL: os.Getenv("MEILISEARCH_URL"),
|
MeilisearchURL: os.Getenv("MEILISEARCH_URL"),
|
||||||
MeilisearchKey: secrets.Env("MEILISEARCH_API_KEY"),
|
MeilisearchKey: secrets.Env("MEILISEARCH_API_KEY"),
|
||||||
|
MeilisearchIndex: envOrDefault("MEILISEARCH_INDEX", "ulti"),
|
||||||
|
TypesenseURL: os.Getenv("TYPESENSE_URL"),
|
||||||
|
TypesenseKey: secrets.Env("TYPESENSE_API_KEY"),
|
||||||
|
TypesenseCollection: envOrDefault("TYPESENSE_COLLECTION", "ulti"),
|
||||||
|
|
||||||
HealthNextcloudURL: envOrDefault("HEALTH_NEXTCLOUD_URL", joinURL(envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"), "/status.php")),
|
HealthNextcloudURL: envOrDefault("HEALTH_NEXTCLOUD_URL", joinURL(envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"), "/status.php")),
|
||||||
HealthImmichURL: envOrDefault("HEALTH_IMMICH_URL", joinURL(envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"), "/server-info/ping")),
|
HealthImmichURL: envOrDefault("HEALTH_IMMICH_URL", joinURL(envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"), "/server-info/ping")),
|
||||||
|
|||||||
333
internal/search/external_clients.go
Normal file
333
internal/search/external_clients.go
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultSearchIndex = "ulti"
|
||||||
|
|
||||||
|
type externalSearchClient interface {
|
||||||
|
Search(ctx context.Context, params ExternalSearchParams) ([]Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalSearchParams struct {
|
||||||
|
ExternalID string
|
||||||
|
Query string
|
||||||
|
Types []string
|
||||||
|
Params query.ListParams
|
||||||
|
Filters SearchFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExternalSearchClient(engine string, opts ServiceOptions) externalSearchClient {
|
||||||
|
switch engine {
|
||||||
|
case "meilisearch":
|
||||||
|
if strings.TrimSpace(opts.MeilisearchURL) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
index := strings.TrimSpace(opts.MeilisearchIndex)
|
||||||
|
if index == "" {
|
||||||
|
index = defaultSearchIndex
|
||||||
|
}
|
||||||
|
return &meilisearchClient{
|
||||||
|
baseURL: strings.TrimRight(strings.TrimSpace(opts.MeilisearchURL), "/"),
|
||||||
|
apiKey: strings.TrimSpace(opts.MeilisearchKey),
|
||||||
|
index: index,
|
||||||
|
http: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
}
|
||||||
|
case "typesense":
|
||||||
|
if strings.TrimSpace(opts.TypesenseURL) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
collection := strings.TrimSpace(opts.TypesenseCollection)
|
||||||
|
if collection == "" {
|
||||||
|
collection = defaultSearchIndex
|
||||||
|
}
|
||||||
|
return &typesenseClient{
|
||||||
|
baseURL: strings.TrimRight(strings.TrimSpace(opts.TypesenseURL), "/"),
|
||||||
|
apiKey: strings.TrimSpace(opts.TypesenseKey),
|
||||||
|
collection: collection,
|
||||||
|
http: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type meilisearchClient struct {
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
index string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *meilisearchClient) Search(ctx context.Context, params ExternalSearchParams) ([]Result, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"q": params.Query,
|
||||||
|
"limit": params.Params.Limit(),
|
||||||
|
"offset": params.Params.Offset(),
|
||||||
|
}
|
||||||
|
if filters := meiliFilters(params); len(filters) > 0 {
|
||||||
|
payload["filter"] = filters
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u := c.baseURL + "/indexes/" + url.PathEscape(c.index) + "/search"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("meilisearch status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Hits []map[string]any `json:"hits"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mapExternalHits(parsed.Hits, params.Query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func meiliFilters(params ExternalSearchParams) []string {
|
||||||
|
filters := make([]string, 0, 5)
|
||||||
|
if params.ExternalID != "" {
|
||||||
|
filters = append(filters, fmt.Sprintf("user_external_id = %q", params.ExternalID))
|
||||||
|
}
|
||||||
|
if len(params.Types) > 0 {
|
||||||
|
escaped := make([]string, 0, len(params.Types))
|
||||||
|
for _, t := range params.Types {
|
||||||
|
escaped = append(escaped, fmt.Sprintf("%q", t))
|
||||||
|
}
|
||||||
|
filters = append(filters, "type IN ["+strings.Join(escaped, ", ")+"]")
|
||||||
|
}
|
||||||
|
if params.Filters.AccountID != "" {
|
||||||
|
filters = append(filters, fmt.Sprintf("account_id = %q", params.Filters.AccountID))
|
||||||
|
}
|
||||||
|
if params.Params.From != nil {
|
||||||
|
filters = append(filters, fmt.Sprintf("date >= %q", params.Params.From.UTC().Format(time.RFC3339)))
|
||||||
|
}
|
||||||
|
if params.Params.To != nil {
|
||||||
|
filters = append(filters, fmt.Sprintf("date <= %q", params.Params.To.UTC().Format(time.RFC3339)))
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
type typesenseClient struct {
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
collection string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *typesenseClient) Search(ctx context.Context, params ExternalSearchParams) ([]Result, error) {
|
||||||
|
u, err := url.Parse(c.baseURL + "/collections/" + url.PathEscape(c.collection) + "/documents/search")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("q", params.Query)
|
||||||
|
q.Set("query_by", "title,snippet,content,name,summary,path")
|
||||||
|
q.Set("per_page", strconv.Itoa(params.Params.Limit()))
|
||||||
|
q.Set("page", strconv.Itoa(params.Params.Page))
|
||||||
|
q.Set("highlight_affix_num_tokens", "6")
|
||||||
|
q.Set("include_fields", "id,type,title,snippet,date,account_id,path,highlights,_text_match")
|
||||||
|
if filter := typesenseFilter(params); filter != "" {
|
||||||
|
q.Set("filter_by", filter)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.apiKey != "" {
|
||||||
|
req.Header.Set("X-TYPESENSE-API-KEY", c.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("typesense status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Hits []struct {
|
||||||
|
Document map[string]any `json:"document"`
|
||||||
|
TextMatch any `json:"text_match"`
|
||||||
|
} `json:"hits"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hits := make([]map[string]any, 0, len(parsed.Hits))
|
||||||
|
for _, h := range parsed.Hits {
|
||||||
|
doc := h.Document
|
||||||
|
if doc == nil {
|
||||||
|
doc = map[string]any{}
|
||||||
|
}
|
||||||
|
doc["_text_match"] = h.TextMatch
|
||||||
|
hits = append(hits, doc)
|
||||||
|
}
|
||||||
|
return mapExternalHits(hits, params.Query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func typesenseFilter(params ExternalSearchParams) string {
|
||||||
|
parts := make([]string, 0, 5)
|
||||||
|
if params.ExternalID != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("user_external_id:=%s", params.ExternalID))
|
||||||
|
}
|
||||||
|
if len(params.Types) > 0 {
|
||||||
|
parts = append(parts, "type:=["+strings.Join(params.Types, ",")+"]")
|
||||||
|
}
|
||||||
|
if params.Filters.AccountID != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("account_id:=%s", params.Filters.AccountID))
|
||||||
|
}
|
||||||
|
if params.Params.From != nil {
|
||||||
|
parts = append(parts, fmt.Sprintf("date:>=%s", params.Params.From.UTC().Format(time.RFC3339)))
|
||||||
|
}
|
||||||
|
if params.Params.To != nil {
|
||||||
|
parts = append(parts, fmt.Sprintf("date:<=%s", params.Params.To.UTC().Format(time.RFC3339)))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " && ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapExternalHits(hits []map[string]any, queryText string) []Result {
|
||||||
|
results := make([]Result, 0, len(hits))
|
||||||
|
for _, hit := range hits {
|
||||||
|
r := Result{
|
||||||
|
Type: asString(firstNonNil(hit["type"], hit["kind"])),
|
||||||
|
ID: asString(firstNonNil(hit["id"], hit["_id"], hit["uid"], hit["path"])),
|
||||||
|
Title: asString(firstNonNil(hit["title"], hit["name"], hit["subject"], hit["summary"])),
|
||||||
|
Snippet: asString(firstNonNil(hit["snippet"], hit["content"], hit["description"], hit["path"])),
|
||||||
|
AccountID: asString(firstNonNil(hit["account_id"], hit["account"], hit["book_id"], hit["calendar_id"])),
|
||||||
|
Score: asFloat(firstNonNil(hit["score"], hit["_rankingScore"], hit["_text_match"])),
|
||||||
|
}
|
||||||
|
if r.Type == "" {
|
||||||
|
r.Type = "mail"
|
||||||
|
}
|
||||||
|
if r.ID == "" {
|
||||||
|
r.ID = r.Type + ":" + r.Title
|
||||||
|
}
|
||||||
|
if dt := asString(firstNonNil(hit["date"], hit["updated_at"], hit["start"])); dt != "" {
|
||||||
|
if parsed, ok := parseDateString(dt); ok {
|
||||||
|
r.Date = parsed.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Snippet == "" {
|
||||||
|
r.Snippet = r.Title
|
||||||
|
}
|
||||||
|
r.Title = highlightTerms(r.Title, queryText)
|
||||||
|
r.Snippet = contextualSnippet(r.Snippet, queryText, 220)
|
||||||
|
if r.Score == 0 {
|
||||||
|
r.Score = textMatchScore(joinNonEmpty(" ", r.Title, r.Snippet), queryText, 1.0)
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeExternalResults(results []Result, queryText string, params query.ListParams) {
|
||||||
|
for i := range results {
|
||||||
|
if strings.TrimSpace(results[i].Title) == "" {
|
||||||
|
results[i].Title = results[i].ID
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(results[i].Snippet) == "" {
|
||||||
|
results[i].Snippet = results[i].Title
|
||||||
|
}
|
||||||
|
results[i].Title = highlightTerms(results[i].Title, queryText)
|
||||||
|
results[i].Snippet = contextualSnippet(results[i].Snippet, queryText, 220)
|
||||||
|
if results[i].Score == 0 {
|
||||||
|
results[i].Score = textMatchScore(joinNonEmpty(" ", results[i].Title, results[i].Snippet), queryText, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ts, ok := dateFromAny(results[i].Date); ok {
|
||||||
|
if params.From != nil && ts.Before(*params.From) {
|
||||||
|
results[i].Score = -1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if params.To != nil && ts.After(*params.To) {
|
||||||
|
results[i].Score = -1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[i].Date = ts.UTC()
|
||||||
|
results[i].Score += recencyBoost(ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonNil(values ...any) any {
|
||||||
|
for _, v := range values {
|
||||||
|
if v != nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func asString(value any) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
case fmt.Stringer:
|
||||||
|
return strings.TrimSpace(v.String())
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func asFloat(value any) float64 {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case float64:
|
||||||
|
return v
|
||||||
|
case float32:
|
||||||
|
return float64(v)
|
||||||
|
case int:
|
||||||
|
return float64(v)
|
||||||
|
case int64:
|
||||||
|
return float64(v)
|
||||||
|
case json.Number:
|
||||||
|
if f, err := v.Float64(); err == nil {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
170
internal/search/external_clients_test.go
Normal file
170
internal/search/external_clients_test.go
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewExternalSearchClient(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
engine string
|
||||||
|
opts ServiceOptions
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "meilisearch with url",
|
||||||
|
engine: "meilisearch",
|
||||||
|
opts: ServiceOptions{MeilisearchURL: "http://localhost:7700"},
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "meilisearch empty url",
|
||||||
|
engine: "meilisearch",
|
||||||
|
opts: ServiceOptions{MeilisearchURL: " "},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "typesense with url",
|
||||||
|
engine: "typesense",
|
||||||
|
opts: ServiceOptions{TypesenseURL: "http://localhost:8108"},
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "typesense empty url",
|
||||||
|
engine: "typesense",
|
||||||
|
opts: ServiceOptions{TypesenseURL: ""},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "postgres engine",
|
||||||
|
engine: "postgres",
|
||||||
|
opts: ServiceOptions{MeilisearchURL: "http://localhost:7700"},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown engine",
|
||||||
|
engine: "elasticsearch",
|
||||||
|
opts: ServiceOptions{TypesenseURL: "http://localhost:8108"},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := newExternalSearchClient(tt.engine, tt.opts)
|
||||||
|
if tt.wantOK && got == nil {
|
||||||
|
t.Fatal("expected non-nil client")
|
||||||
|
}
|
||||||
|
if !tt.wantOK && got != nil {
|
||||||
|
t.Fatal("expected nil client")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapExternalHits(t *testing.T) {
|
||||||
|
hits := []map[string]any{
|
||||||
|
{
|
||||||
|
"id": "mail-42",
|
||||||
|
"type": "mail",
|
||||||
|
"title": "Invoice from Acme",
|
||||||
|
"snippet": "Your Acme invoice is ready",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results := mapExternalHits(hits, "acme")
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("len(results) = %d, want 1", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
r := results[0]
|
||||||
|
if r.Type != "mail" {
|
||||||
|
t.Fatalf("Type = %q, want mail", r.Type)
|
||||||
|
}
|
||||||
|
if r.ID != "mail-42" {
|
||||||
|
t.Fatalf("ID = %q, want mail-42", r.ID)
|
||||||
|
}
|
||||||
|
if r.Title == "" {
|
||||||
|
t.Fatal("Title is empty")
|
||||||
|
}
|
||||||
|
if r.Snippet == "" {
|
||||||
|
t.Fatal("Snippet is empty")
|
||||||
|
}
|
||||||
|
if r.Score <= 0 {
|
||||||
|
t.Fatalf("Score = %v, want positive fallback score", r.Score)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.Title, "<mark>") {
|
||||||
|
t.Fatalf("Title missing highlight: %q", r.Title)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.Snippet, "<mark>") {
|
||||||
|
t.Fatalf("Snippet missing highlight: %q", r.Snippet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapExternalHitsMinimalDefaults(t *testing.T) {
|
||||||
|
hits := []map[string]any{
|
||||||
|
{"title": "Budget report"},
|
||||||
|
}
|
||||||
|
|
||||||
|
results := mapExternalHits(hits, "budget")
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("len(results) = %d, want 1", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
r := results[0]
|
||||||
|
if r.Type != "mail" {
|
||||||
|
t.Fatalf("Type = %q, want default mail", r.Type)
|
||||||
|
}
|
||||||
|
if r.ID != "mail:Budget report" {
|
||||||
|
t.Fatalf("ID = %q, want mail:Budget report", r.ID)
|
||||||
|
}
|
||||||
|
if r.Snippet == "" {
|
||||||
|
t.Fatal("Snippet is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalizeExternalResultsDateFiltering(t *testing.T) {
|
||||||
|
mid := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
|
||||||
|
from := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
to := time.Date(2024, 6, 30, 23, 59, 59, 0, time.UTC)
|
||||||
|
|
||||||
|
results := []Result{
|
||||||
|
{ID: "before", Title: "Old mail", Snippet: "Old mail", Date: time.Date(2024, 5, 15, 12, 0, 0, 0, time.UTC), Score: 5},
|
||||||
|
{ID: "in-range", Title: "Current mail", Snippet: "Current mail", Date: mid, Score: 5},
|
||||||
|
{ID: "after", Title: "Future mail", Snippet: "Future mail", Date: time.Date(2024, 7, 15, 12, 0, 0, 0, time.UTC), Score: 5},
|
||||||
|
}
|
||||||
|
|
||||||
|
params := query.ListParams{From: &from, To: &to}
|
||||||
|
finalizeExternalResults(results, "mail", params)
|
||||||
|
|
||||||
|
if results[0].Score >= 0 {
|
||||||
|
t.Fatalf("before-range Score = %v, want negative", results[0].Score)
|
||||||
|
}
|
||||||
|
if results[1].Score <= 0 {
|
||||||
|
t.Fatalf("in-range Score = %v, want positive with recency boost", results[1].Score)
|
||||||
|
}
|
||||||
|
if results[2].Score >= 0 {
|
||||||
|
t.Fatalf("after-range Score = %v, want negative", results[2].Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalizeExternalResultsFallbackScore(t *testing.T) {
|
||||||
|
results := []Result{
|
||||||
|
{ID: "1", Title: "Invoice details", Snippet: "Invoice details", Score: 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeExternalResults(results, "invoice", query.ListParams{})
|
||||||
|
if results[0].Score <= 0 {
|
||||||
|
t.Fatalf("Score = %v, want positive fallback score", results[0].Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsStringNil(t *testing.T) {
|
||||||
|
if got := asString(nil); got != "" {
|
||||||
|
t.Fatalf("asString(nil) = %q, want empty string", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package search
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
@ -10,6 +11,7 @@ import (
|
|||||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@ -17,9 +19,29 @@ type Handler struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *pgxpool.Pool) *Handler {
|
type Options struct {
|
||||||
|
Nextcloud *nextcloud.Client
|
||||||
|
Engine string
|
||||||
|
MeilisearchURL string
|
||||||
|
MeilisearchKey string
|
||||||
|
MeilisearchIndex string
|
||||||
|
TypesenseURL string
|
||||||
|
TypesenseKey string
|
||||||
|
TypesenseCollection string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *pgxpool.Pool, opts Options) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
svc: NewService(db),
|
svc: NewService(db, ServiceOptions{
|
||||||
|
Nextcloud: opts.Nextcloud,
|
||||||
|
Engine: opts.Engine,
|
||||||
|
MeilisearchURL: opts.MeilisearchURL,
|
||||||
|
MeilisearchKey: opts.MeilisearchKey,
|
||||||
|
MeilisearchIndex: opts.MeilisearchIndex,
|
||||||
|
TypesenseURL: opts.TypesenseURL,
|
||||||
|
TypesenseKey: opts.TypesenseKey,
|
||||||
|
TypesenseCollection: opts.TypesenseCollection,
|
||||||
|
}),
|
||||||
logger: slog.Default().With("component", "search"),
|
logger: slog.Default().With("component", "search"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -44,8 +66,11 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
apivalidate.WriteQueryError(w, r, err)
|
apivalidate.WriteQueryError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
filters := SearchFilters{
|
||||||
|
AccountID: strings.TrimSpace(r.URL.Query().Get("account_id")),
|
||||||
|
}
|
||||||
|
|
||||||
result, err := h.svc.Search(r.Context(), claims.Sub, q, types, params)
|
result, err := h.svc.Search(r.Context(), claims.Sub, q, types, params, filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("search", "error", err)
|
h.logger.Error("search", "error", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
apivalidate.WriteInternal(w, r)
|
||||||
|
|||||||
@ -2,19 +2,45 @@ package search
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *pgxpool.Pool
|
db *pgxpool.Pool
|
||||||
|
nc *nextcloud.Client
|
||||||
|
engine string
|
||||||
|
external externalSearchClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(db *pgxpool.Pool) *Service {
|
type ServiceOptions struct {
|
||||||
return &Service{db: db}
|
Nextcloud *nextcloud.Client
|
||||||
|
Engine string
|
||||||
|
MeilisearchURL string
|
||||||
|
MeilisearchKey string
|
||||||
|
MeilisearchIndex string
|
||||||
|
TypesenseURL string
|
||||||
|
TypesenseKey string
|
||||||
|
TypesenseCollection string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *pgxpool.Pool, opts ServiceOptions) *Service {
|
||||||
|
engine := normalizeEngine(opts.Engine)
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
nc: opts.Nextcloud,
|
||||||
|
engine: engine,
|
||||||
|
external: newExternalSearchClient(engine, opts),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@ -23,60 +49,189 @@ type Result struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Snippet string `json:"snippet"`
|
Snippet string `json:"snippet"`
|
||||||
Date any `json:"date,omitempty"`
|
Date any `json:"date,omitempty"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
Score float64 `json:"score,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchResponse struct {
|
type SearchResponse struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
|
Engine string `json:"engine,omitempty"`
|
||||||
Results []Result `json:"results"`
|
Results []Result `json:"results"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Search(ctx context.Context, externalID, q, typesRaw string, params query.ListParams) (SearchResponse, error) {
|
type SearchFilters struct {
|
||||||
typeList := parseTypes(typesRaw)
|
AccountID string
|
||||||
tsQuery := toTSQuery(q)
|
}
|
||||||
|
|
||||||
allResults := make([]Result, 0)
|
func (s *Service) Search(ctx context.Context, externalID, q, typesRaw string, params query.ListParams, filters SearchFilters) (SearchResponse, error) {
|
||||||
for _, t := range typeList {
|
typeList := parseTypes(typesRaw)
|
||||||
switch t {
|
usedEngine := s.engine
|
||||||
case "mail":
|
if s.external == nil && s.engine != "postgres" {
|
||||||
mailResults, err := s.searchMail(ctx, externalID, tsQuery, params)
|
usedEngine = "postgres"
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
allResults []Result
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if s.external != nil {
|
||||||
|
allResults, err = s.external.Search(ctx, ExternalSearchParams{
|
||||||
|
ExternalID: externalID,
|
||||||
|
Query: q,
|
||||||
|
Types: typeList,
|
||||||
|
Params: params,
|
||||||
|
Filters: filters,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
usedEngine = "postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.external == nil || err != nil {
|
||||||
|
allResults, err = s.searchFederated(ctx, externalID, q, typeList, params, filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SearchResponse{}, err
|
return SearchResponse{}, err
|
||||||
}
|
}
|
||||||
allResults = append(allResults, mailResults...)
|
|
||||||
case "contacts":
|
|
||||||
contactResults, err := s.searchContacts(ctx, q, params)
|
|
||||||
if err != nil {
|
|
||||||
return SearchResponse{}, err
|
|
||||||
}
|
|
||||||
allResults = append(allResults, contactResults...)
|
|
||||||
case "events":
|
|
||||||
// Events are in Nextcloud CalDAV, not in PG - skip for now.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalizeExternalResults(allResults, q, params)
|
||||||
|
allResults = discardFilteredResults(allResults)
|
||||||
|
sortResultsByScore(allResults)
|
||||||
|
|
||||||
total := int64(len(allResults))
|
total := int64(len(allResults))
|
||||||
page, _ := paginateResults(allResults, params.Offset(), params.Limit())
|
page, _ := paginateResults(allResults, params.Offset(), params.Limit())
|
||||||
|
|
||||||
return SearchResponse{
|
return SearchResponse{
|
||||||
Query: q,
|
Query: q,
|
||||||
|
Engine: usedEngine,
|
||||||
Results: page,
|
Results: page,
|
||||||
Count: len(page),
|
Count: len(page),
|
||||||
Pagination: params.Meta(&total),
|
Pagination: params.Meta(&total),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) searchFederated(ctx context.Context, externalID, q string, typeList []string, params query.ListParams, filters SearchFilters) ([]Result, error) {
|
||||||
|
allResults := make([]Result, 0, params.Offset()+params.Limit())
|
||||||
|
for _, t := range typeList {
|
||||||
|
switch t {
|
||||||
|
case "mail":
|
||||||
|
mailResults, err := s.searchMail(ctx, externalID, q, params, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allResults = append(allResults, mailResults...)
|
||||||
|
case "contacts":
|
||||||
|
contactResults, err := s.searchContacts(ctx, externalID, q, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allResults = append(allResults, contactResults...)
|
||||||
|
case "files":
|
||||||
|
fileResults, err := s.searchFiles(ctx, externalID, q, params, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allResults = append(allResults, fileResults...)
|
||||||
|
case "events":
|
||||||
|
eventResults, err := s.searchEvents(ctx, externalID, q, params, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allResults = append(allResults, eventResults...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allResults, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortResultsByScore(results []Result) {
|
||||||
|
sort.SliceStable(results, func(i, j int) bool {
|
||||||
|
if results[i].Score == results[j].Score {
|
||||||
|
ti, okI := dateFromAny(results[i].Date)
|
||||||
|
tj, okJ := dateFromAny(results[j].Date)
|
||||||
|
if okI && okJ {
|
||||||
|
return ti.After(tj)
|
||||||
|
}
|
||||||
|
return okI
|
||||||
|
}
|
||||||
|
return results[i].Score > results[j].Score
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dateFromAny(raw any) (time.Time, bool) {
|
||||||
|
switch t := raw.(type) {
|
||||||
|
case time.Time:
|
||||||
|
return t, true
|
||||||
|
case *time.Time:
|
||||||
|
if t == nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return *t, true
|
||||||
|
case string:
|
||||||
|
return parseDateString(t)
|
||||||
|
default:
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDateString(raw string) (time.Time, bool) {
|
||||||
|
layouts := []string{
|
||||||
|
time.RFC3339,
|
||||||
|
"20060102T150405Z",
|
||||||
|
"20060102T150405",
|
||||||
|
"20060102",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if ts, err := time.Parse(layout, raw); err == nil {
|
||||||
|
return ts, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEngine(raw string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "meilisearch":
|
||||||
|
return "meilisearch"
|
||||||
|
case "typesense":
|
||||||
|
return "typesense"
|
||||||
|
default:
|
||||||
|
return "postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultCalendarRange(params query.ListParams) (time.Time, time.Time) {
|
||||||
|
from := time.Now().UTC().AddDate(0, -6, 0)
|
||||||
|
to := time.Now().UTC().AddDate(1, 0, 0)
|
||||||
|
if params.From != nil {
|
||||||
|
from = params.From.UTC()
|
||||||
|
}
|
||||||
|
if params.To != nil {
|
||||||
|
to = params.To.UTC()
|
||||||
|
}
|
||||||
|
if from.After(to) {
|
||||||
|
from, to = to, from
|
||||||
|
}
|
||||||
|
return from, to
|
||||||
|
}
|
||||||
|
|
||||||
func parseTypes(typesRaw string) []string {
|
func parseTypes(typesRaw string) []string {
|
||||||
if strings.TrimSpace(typesRaw) == "" {
|
if strings.TrimSpace(typesRaw) == "" {
|
||||||
return []string{"mail", "contacts", "events"}
|
return []string{"mail", "contacts", "files", "events"}
|
||||||
}
|
}
|
||||||
parts := strings.Split(typesRaw, ",")
|
parts := strings.Split(typesRaw, ",")
|
||||||
out := make([]string, 0, len(parts))
|
out := make([]string, 0, len(parts))
|
||||||
|
seen := make(map[string]struct{}, len(parts))
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
if t := strings.TrimSpace(p); t != "" {
|
t := strings.TrimSpace(p)
|
||||||
out = append(out, t)
|
if t == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if _, ok := seen[t]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[t] = struct{}{}
|
||||||
|
out = append(out, t)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@ -93,22 +248,57 @@ func paginateResults(results []Result, offset, limit int) ([]Result, int64) {
|
|||||||
return results[offset:end], total
|
return results[offset:end], total
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) searchMail(ctx context.Context, externalID, tsQuery string, params query.ListParams) ([]Result, error) {
|
func (s *Service) searchMail(ctx context.Context, externalID, queryText string, params query.ListParams, filters SearchFilters) ([]Result, error) {
|
||||||
|
tsQuery := toTSQuery(queryText)
|
||||||
|
if tsQuery == "" {
|
||||||
|
return []Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
limit := params.Offset() + params.Limit()
|
limit := params.Offset() + params.Limit()
|
||||||
if limit < 20 {
|
if limit < 20 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.Query(ctx, `
|
args := []any{tsQuery, externalID}
|
||||||
SELECT m.id, m.subject, m.snippet, m.date,
|
argIdx := 3
|
||||||
ts_rank(m.search_vector, to_tsquery('simple', $1)) as rank
|
base := `
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN mail_accounts ma ON m.account_id = ma.id
|
JOIN mail_accounts ma ON m.account_id = ma.id
|
||||||
WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||||
AND m.search_vector @@ to_tsquery('simple', $1)
|
AND m.search_vector @@ to_tsquery('simple', $1)
|
||||||
ORDER BY rank DESC
|
`
|
||||||
LIMIT $3
|
if filters.AccountID != "" {
|
||||||
`, tsQuery, externalID, limit)
|
base += fmt.Sprintf(" AND m.account_id = $%d", argIdx)
|
||||||
|
args = append(args, filters.AccountID)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if params.From != nil {
|
||||||
|
base += fmt.Sprintf(" AND m.date >= $%d", argIdx)
|
||||||
|
args = append(args, params.From.UTC())
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if params.To != nil {
|
||||||
|
base += fmt.Sprintf(" AND m.date <= $%d", argIdx)
|
||||||
|
args = append(args, params.To.UTC())
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
querySQL := `
|
||||||
|
SELECT m.id, m.account_id, m.subject, m.snippet, m.date,
|
||||||
|
ts_rank(m.search_vector, to_tsquery('simple', $1)) as rank,
|
||||||
|
ts_headline(
|
||||||
|
'simple',
|
||||||
|
COALESCE(m.subject, '') || ' ' || COALESCE(m.snippet, ''),
|
||||||
|
to_tsquery('simple', $1),
|
||||||
|
'StartSel=<mark>,StopSel=</mark>,MaxWords=24,MinWords=8,MaxFragments=2'
|
||||||
|
) as highlighted
|
||||||
|
` + base + fmt.Sprintf(`
|
||||||
|
ORDER BY rank DESC, m.date DESC
|
||||||
|
LIMIT $%d
|
||||||
|
`, argIdx)
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := s.db.Query(ctx, querySQL, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -116,18 +306,25 @@ func (s *Service) searchMail(ctx context.Context, externalID, tsQuery string, pa
|
|||||||
|
|
||||||
results := make([]Result, 0)
|
results := make([]Result, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, subject, snippet string
|
var id, accountID, subject, snippet string
|
||||||
var date any
|
var date time.Time
|
||||||
var rank float64
|
var rank float64
|
||||||
if err := rows.Scan(&id, &subject, &snippet, &date, &rank); err != nil {
|
var highlighted string
|
||||||
|
if err := rows.Scan(&id, &accountID, &subject, &snippet, &date, &rank, &highlighted); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
finalSnippet := strings.TrimSpace(highlighted)
|
||||||
|
if finalSnippet == "" {
|
||||||
|
finalSnippet = contextualSnippet(snippet, queryText, 220)
|
||||||
|
}
|
||||||
results = append(results, Result{
|
results = append(results, Result{
|
||||||
Type: "mail",
|
Type: "mail",
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: subject,
|
Title: highlightTerms(subject, queryText),
|
||||||
Snippet: snippet,
|
Snippet: finalSnippet,
|
||||||
Date: date,
|
Date: date.UTC(),
|
||||||
|
AccountID: accountID,
|
||||||
|
Score: rank + recencyBoost(date),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@ -136,45 +333,362 @@ func (s *Service) searchMail(ctx context.Context, externalID, tsQuery string, pa
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) searchContacts(ctx context.Context, queryText string, params query.ListParams) ([]Result, error) {
|
func (s *Service) searchContacts(ctx context.Context, userID, queryText string, filters SearchFilters) ([]Result, error) {
|
||||||
limit := params.Offset() + params.Limit()
|
if s.nc == nil {
|
||||||
if limit < 10 {
|
return []Result{}, nil
|
||||||
limit = 10
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.Query(ctx, `
|
bookIDs := make([]string, 0, 4)
|
||||||
SELECT id, name, email FROM users
|
if filters.AccountID != "" {
|
||||||
WHERE (name ILIKE '%' || $1 || '%' OR email ILIKE '%' || $1 || '%')
|
bookIDs = append(bookIDs, filters.AccountID)
|
||||||
LIMIT $2
|
} else {
|
||||||
`, queryText, limit)
|
books, err := s.nc.ListAddressBooks(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
for _, b := range books {
|
||||||
|
if b.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bookIDs = append(bookIDs, b.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
results := make([]Result, 0)
|
seen := make(map[string]struct{})
|
||||||
for rows.Next() {
|
results := make([]Result, 0, 16)
|
||||||
var id, name, email string
|
for _, bookID := range bookIDs {
|
||||||
if err := rows.Scan(&id, &name, &email); err != nil {
|
contacts, err := s.nc.ListContacts(ctx, userID, cardBookPath(userID, bookID))
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
for _, c := range contacts {
|
||||||
|
content := strings.TrimSpace(strings.Join([]string{c.FullName, c.Email, c.Phone, c.Org}, " "))
|
||||||
|
if !matchesQuery(content, queryText) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(bookID + ":" + c.UID + ":" + c.Email)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
|
||||||
|
title := strings.TrimSpace(c.FullName)
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(c.Email)
|
||||||
|
}
|
||||||
|
snippetSrc := joinNonEmpty(" • ", c.Email, c.Phone, c.Org)
|
||||||
results = append(results, Result{
|
results = append(results, Result{
|
||||||
Type: "contact",
|
Type: "contacts",
|
||||||
ID: id,
|
ID: key,
|
||||||
Title: name,
|
Title: highlightTerms(title, queryText),
|
||||||
Snippet: email,
|
Snippet: contextualSnippet(snippetSrc, queryText, 180),
|
||||||
|
AccountID: bookID,
|
||||||
|
Score: textMatchScore(content, queryText, 1.2),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) searchEvents(ctx context.Context, userID, queryText string, params query.ListParams, filters SearchFilters) ([]Result, error) {
|
||||||
|
if s.nc == nil {
|
||||||
|
return []Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
from, to := defaultCalendarRange(params)
|
||||||
|
calIDs := make([]string, 0, 4)
|
||||||
|
if filters.AccountID != "" {
|
||||||
|
calIDs = append(calIDs, filters.AccountID)
|
||||||
|
} else {
|
||||||
|
cals, err := s.nc.ListCalendars(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, c := range cals {
|
||||||
|
if c.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
calIDs = append(calIDs, c.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
results := make([]Result, 0, 16)
|
||||||
|
for _, calID := range calIDs {
|
||||||
|
events, err := s.nc.ListEvents(ctx, userID, calPath(userID, calID), from, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, ev := range events {
|
||||||
|
content := strings.TrimSpace(strings.Join([]string{ev.Summary, ev.Description, ev.Location}, " "))
|
||||||
|
if !matchesQuery(content, queryText) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eventDate, _ := parseDateString(ev.Start)
|
||||||
|
key := strings.TrimSpace(calID + ":" + ev.UID + ":" + ev.Start)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
|
||||||
|
snippetSrc := joinNonEmpty(" • ", ev.Description, ev.Location)
|
||||||
|
result := Result{
|
||||||
|
Type: "events",
|
||||||
|
ID: key,
|
||||||
|
Title: highlightTerms(ev.Summary, queryText),
|
||||||
|
Snippet: contextualSnippet(snippetSrc, queryText, 200),
|
||||||
|
AccountID: calID,
|
||||||
|
Score: textMatchScore(content, queryText, 1.1),
|
||||||
|
}
|
||||||
|
if !eventDate.IsZero() {
|
||||||
|
result.Date = eventDate.UTC()
|
||||||
|
result.Score += recencyBoost(eventDate)
|
||||||
|
}
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) searchFiles(ctx context.Context, userID, queryText string, params query.ListParams, filters SearchFilters) ([]Result, error) {
|
||||||
|
if s.nc == nil {
|
||||||
|
return []Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
basePath := "/"
|
||||||
|
if strings.TrimSpace(filters.AccountID) != "" {
|
||||||
|
basePath = strings.TrimSpace(filters.AccountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := s.nc.ListFiles(ctx, userID, basePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]Result, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
content := strings.TrimSpace(strings.Join([]string{f.Name, f.Path, f.MimeType}, " "))
|
||||||
|
if !matchesQuery(content, queryText) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var modifiedTime time.Time
|
||||||
|
if ts, err := time.Parse(time.RFC1123, strings.TrimSpace(f.LastModified)); err == nil {
|
||||||
|
modifiedTime = ts
|
||||||
|
}
|
||||||
|
if !modifiedTime.IsZero() {
|
||||||
|
if params.From != nil && modifiedTime.Before(*params.From) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if params.To != nil && modifiedTime.After(*params.To) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := Result{
|
||||||
|
Type: "files",
|
||||||
|
ID: f.Path,
|
||||||
|
Title: highlightTerms(f.Name, queryText),
|
||||||
|
Snippet: contextualSnippet(joinNonEmpty(" • ", f.Path, f.MimeType), queryText, 220),
|
||||||
|
AccountID: basePath,
|
||||||
|
Score: textMatchScore(content, queryText, 1.0),
|
||||||
|
}
|
||||||
|
if !modifiedTime.IsZero() {
|
||||||
|
result.Date = modifiedTime.UTC()
|
||||||
|
result.Score += recencyBoost(modifiedTime)
|
||||||
|
}
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recencyBoost(ts time.Time) float64 {
|
||||||
|
if ts.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
days := time.Since(ts).Hours() / 24
|
||||||
|
if days < 0 {
|
||||||
|
days = -days / 2
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case days <= 1:
|
||||||
|
return 1.5
|
||||||
|
case days <= 7:
|
||||||
|
return 1.0
|
||||||
|
case days <= 30:
|
||||||
|
return 0.6
|
||||||
|
case days <= 180:
|
||||||
|
return 0.3
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textMatchScore(content, q string, weight float64) float64 {
|
||||||
|
content = strings.ToLower(strings.TrimSpace(content))
|
||||||
|
q = strings.ToLower(strings.TrimSpace(q))
|
||||||
|
if content == "" || q == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
score := 0.0
|
||||||
|
if strings.Contains(content, q) {
|
||||||
|
score += 3.0 * weight
|
||||||
|
}
|
||||||
|
for _, term := range splitSearchTerms(q) {
|
||||||
|
hits := strings.Count(content, term)
|
||||||
|
if hits == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score += float64(hits) * weight
|
||||||
|
if strings.HasPrefix(content, term) {
|
||||||
|
score += 0.25 * weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
func toTSQuery(input string) string {
|
func toTSQuery(input string) string {
|
||||||
words := strings.Fields(input)
|
terms := splitSearchTerms(input)
|
||||||
for i, w := range words {
|
if len(terms) == 0 {
|
||||||
words[i] = w + ":*"
|
return ""
|
||||||
}
|
}
|
||||||
return strings.Join(words, " & ")
|
out := make([]string, 0, len(terms))
|
||||||
|
for _, term := range terms {
|
||||||
|
out = append(out, term+":*")
|
||||||
|
}
|
||||||
|
return strings.Join(out, " & ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSearchTerms(input string) []string {
|
||||||
|
rawWords := strings.Fields(strings.ToLower(strings.TrimSpace(input)))
|
||||||
|
terms := make([]string, 0, len(rawWords))
|
||||||
|
seen := make(map[string]struct{}, len(rawWords))
|
||||||
|
for _, raw := range rawWords {
|
||||||
|
clean := strings.Map(func(r rune) rune {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return ' '
|
||||||
|
}, raw)
|
||||||
|
for _, part := range strings.Fields(clean) {
|
||||||
|
if len(part) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[part]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[part] = struct{}{}
|
||||||
|
terms = append(terms, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return terms
|
||||||
|
}
|
||||||
|
|
||||||
|
func highlightTerms(text, q string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
terms := splitSearchTerms(q)
|
||||||
|
if len(terms) == 0 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
patterns := make([]string, 0, len(terms))
|
||||||
|
for _, term := range terms {
|
||||||
|
patterns = append(patterns, regexp.QuoteMeta(term))
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`(?i)\b(` + strings.Join(patterns, "|") + `)\b`)
|
||||||
|
return re.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
return "<mark>" + match + "</mark>"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextualSnippet(text, q string, maxLen int) string {
|
||||||
|
text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if maxLen <= 0 {
|
||||||
|
maxLen = 180
|
||||||
|
}
|
||||||
|
|
||||||
|
snippet := text
|
||||||
|
if len(text) > maxLen {
|
||||||
|
snippet = text[:maxLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
terms := splitSearchTerms(q)
|
||||||
|
lower := strings.ToLower(text)
|
||||||
|
bestIdx := -1
|
||||||
|
for _, term := range terms {
|
||||||
|
idx := strings.Index(lower, term)
|
||||||
|
if idx >= 0 {
|
||||||
|
bestIdx = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestIdx >= 0 {
|
||||||
|
start := bestIdx - (maxLen / 3)
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := start + maxLen
|
||||||
|
if end > len(text) {
|
||||||
|
end = len(text)
|
||||||
|
}
|
||||||
|
snippet = text[start:end]
|
||||||
|
if start > 0 {
|
||||||
|
snippet = "..." + snippet
|
||||||
|
}
|
||||||
|
if end < len(text) {
|
||||||
|
snippet += "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlightTerms(snippet, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesQuery(content, q string) bool {
|
||||||
|
terms := splitSearchTerms(q)
|
||||||
|
if len(terms) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
content = strings.ToLower(content)
|
||||||
|
for _, term := range terms {
|
||||||
|
if strings.Contains(content, term) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinNonEmpty(sep string, parts ...string) string {
|
||||||
|
filtered := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
filtered = append(filtered, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(filtered, sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardFilteredResults(results []Result) []Result {
|
||||||
|
out := make([]Result, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Score < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cardBookPath(userID, bookID string) string {
|
||||||
|
return "/remote.php/dav/addressbooks/users/" + userID + "/" + bookID + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func calPath(userID, calID string) string {
|
||||||
|
return "/remote.php/dav/calendars/" + userID + "/" + calID + "/"
|
||||||
}
|
}
|
||||||
|
|||||||
184
internal/search/service_test.go
Normal file
184
internal/search/service_test.go
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeEngine(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "postgres explicit", raw: "postgres", want: "postgres"},
|
||||||
|
{name: "meilisearch", raw: "meilisearch", want: "meilisearch"},
|
||||||
|
{name: "meilisearch mixed case", raw: "MeiliSearch", want: "meilisearch"},
|
||||||
|
{name: "meilisearch trimmed", raw: " meilisearch ", want: "meilisearch"},
|
||||||
|
{name: "typesense", raw: "typesense", want: "typesense"},
|
||||||
|
{name: "typesense mixed case", raw: "TypeSense", want: "typesense"},
|
||||||
|
{name: "empty defaults postgres", raw: "", want: "postgres"},
|
||||||
|
{name: "unknown defaults postgres", raw: "elasticsearch", want: "postgres"},
|
||||||
|
{name: "whitespace defaults postgres", raw: " ", want: "postgres"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := normalizeEngine(tt.raw); got != tt.want {
|
||||||
|
t.Fatalf("normalizeEngine(%q) = %q, want %q", tt.raw, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTypes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty defaults all",
|
||||||
|
raw: "",
|
||||||
|
want: []string{"mail", "contacts", "files", "events"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace defaults all",
|
||||||
|
raw: " ",
|
||||||
|
want: []string{"mail", "contacts", "files", "events"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit subset",
|
||||||
|
raw: "mail,contacts",
|
||||||
|
want: []string{"mail", "contacts"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deduplicates",
|
||||||
|
raw: "mail,mail,contacts,contacts",
|
||||||
|
want: []string{"mail", "contacts"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trims and skips empty parts",
|
||||||
|
raw: " mail , , contacts ",
|
||||||
|
want: []string{"mail", "contacts"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preserves order",
|
||||||
|
raw: "events,mail,contacts",
|
||||||
|
want: []string{"events", "mail", "contacts"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseTypes(tt.raw)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("parseTypes(%q) = %v, want %v", tt.raw, got, tt.want)
|
||||||
|
}
|
||||||
|
for i := range tt.want {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Fatalf("parseTypes(%q)[%d] = %q, want %q (full: %v)", tt.raw, i, got[i], tt.want[i], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSearchTerms(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "empty", input: "", want: nil},
|
||||||
|
{name: "whitespace", input: " ", want: nil},
|
||||||
|
{name: "simple words", input: "hello world", want: []string{"hello", "world"}},
|
||||||
|
{name: "strips punctuation", input: "hello, world!", want: []string{"hello", "world"}},
|
||||||
|
{name: "deduplicates", input: "foo foo bar", want: []string{"foo", "bar"}},
|
||||||
|
{name: "mixed punctuation", input: "can't re-read", want: []string{"can", "t", "re", "read"}},
|
||||||
|
{name: "case normalized", input: "Hello HELLO", want: []string{"hello"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := splitSearchTerms(tt.input)
|
||||||
|
if len(got) == 0 && len(tt.want) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("splitSearchTerms(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
for i := range tt.want {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Fatalf("splitSearchTerms(%q)[%d] = %q, want %q (full: %v)", tt.input, i, got[i], tt.want[i], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHighlightTerms(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
q string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty text", text: "", q: "hello", want: ""},
|
||||||
|
{name: "empty query unchanged", text: "Hello World", q: "", want: "Hello World"},
|
||||||
|
{name: "case insensitive preserves match casing", text: "Hello World", q: "hello", want: "<mark>Hello</mark> World"},
|
||||||
|
{name: "multiple terms", text: "Meet Alice and Bob", q: "alice bob", want: "Meet <mark>Alice</mark> and <mark>Bob</mark>"},
|
||||||
|
{name: "word boundary only", text: "hellish hello", q: "hello", want: "hellish <mark>hello</mark>"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := highlightTerms(tt.text, tt.q); got != tt.want {
|
||||||
|
t.Fatalf("highlightTerms(%q, %q) = %q, want %q", tt.text, tt.q, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextualSnippet(t *testing.T) {
|
||||||
|
t.Run("highlights match in short text", func(t *testing.T) {
|
||||||
|
got := contextualSnippet("Invoice from Acme Corp", "acme", 180)
|
||||||
|
want := "Invoice from <mark>Acme</mark> Corp"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("contextualSnippet short = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("trims long content around match", func(t *testing.T) {
|
||||||
|
prefix := strings.Repeat("word ", 30)
|
||||||
|
text := prefix + "needle here " + strings.Repeat("tail ", 30)
|
||||||
|
got := contextualSnippet(text, "needle", 40)
|
||||||
|
|
||||||
|
if !strings.Contains(got, "<mark>needle</mark>") {
|
||||||
|
t.Fatalf("expected highlighted needle, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(got, "...") {
|
||||||
|
t.Fatalf("expected leading ellipsis for trimmed snippet, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got, "...") {
|
||||||
|
t.Fatalf("expected trailing ellipsis for trimmed snippet, got %q", got)
|
||||||
|
}
|
||||||
|
if len(got) > 80 {
|
||||||
|
t.Fatalf("snippet too long (%d chars): %q", len(got), got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty text", func(t *testing.T) {
|
||||||
|
if got := contextualSnippet(" ", "foo", 100); got != "" {
|
||||||
|
t.Fatalf("contextualSnippet empty = %q, want empty", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("default maxLen when zero", func(t *testing.T) {
|
||||||
|
long := strings.Repeat("x", 300) + " findme"
|
||||||
|
got := contextualSnippet(long, "findme", 0)
|
||||||
|
if !strings.Contains(got, "<mark>findme</mark>") {
|
||||||
|
t.Fatalf("expected highlighted findme with default maxLen, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ const (
|
|||||||
var allowedSearchTypes = map[string]struct{}{
|
var allowedSearchTypes = map[string]struct{}{
|
||||||
"mail": {},
|
"mail": {},
|
||||||
"contacts": {},
|
"contacts": {},
|
||||||
|
"files": {},
|
||||||
"events": {},
|
"events": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -128,11 +128,11 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
|
|||||||
|
|
||||||
### 2.6 Search
|
### 2.6 Search
|
||||||
|
|
||||||
- [ ] Finaliser recherche events (Agenda) et contacts (CardDAV), pas fallback users local.
|
- [x] Finaliser recherche events (Agenda) et contacts (CardDAV), pas fallback users local.
|
||||||
- [ ] Ajouter recherche multi-index (mail+contacts+files+events) avec score unifié.
|
- [x] Ajouter recherche multi-index (mail+contacts+files+events) avec score unifié.
|
||||||
- [ ] Ajouter snippets contextuels et highlighting.
|
- [x] Ajouter snippets contextuels et highlighting.
|
||||||
- [ ] Ajouter filtres type/date/account.
|
- [x] Ajouter filtres type/date/account.
|
||||||
- [ ] Préparer option Meilisearch/Typesense activable.
|
- [x] Préparer option Meilisearch/Typesense activable.
|
||||||
|
|
||||||
### 2.7 Modules suite (Drive/Calendar/Contacts/Meet/Photos/Admin)
|
### 2.7 Modules suite (Drive/Calendar/Contacts/Meet/Photos/Admin)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user