| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- // Copyright 2023 The Gitea Authors. All rights reserved.
- // SPDX-License-Identifier: MIT
-
- package meilisearch
-
- import (
- "context"
- "errors"
- "fmt"
- "strconv"
- "strings"
-
- "code.gitea.io/gitea/modules/indexer"
- indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
- inner_meilisearch "code.gitea.io/gitea/modules/indexer/internal/meilisearch"
- "code.gitea.io/gitea/modules/indexer/issues/internal"
- "code.gitea.io/gitea/modules/json"
-
- "github.com/meilisearch/meilisearch-go"
- )
-
- const (
- issueIndexerLatestVersion = 4
-
- // TODO: make this configurable if necessary
- maxTotalHits = 10000
- )
-
- // ErrMalformedResponse is never expected as we initialize the indexer ourself and so define the types.
- var ErrMalformedResponse = errors.New("meilisearch returned unexpected malformed content")
-
- var _ internal.Indexer = &Indexer{}
-
- // Indexer implements Indexer interface
- type Indexer struct {
- inner *inner_meilisearch.Indexer
- indexer_internal.Indexer // do not composite inner_meilisearch.Indexer directly to avoid exposing too much
- }
-
- func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
- return indexer.SearchModesExactWords()
- }
-
- // NewIndexer creates a new meilisearch indexer
- func NewIndexer(url, apiKey, indexerName string) *Indexer {
- settings := &meilisearch.Settings{
- // The default ranking rules of meilisearch are: ["words", "typo", "proximity", "attribute", "sort", "exactness"]
- // So even if we specify the sort order, it could not be respected because the priority of "sort" is so low.
- // So we need to specify the ranking rules to make sure the sort order is respected.
- // See https://www.meilisearch.com/docs/learn/core_concepts/relevancy
- RankingRules: []string{"sort", // make sure "sort" has the highest priority
- "words", "typo", "proximity", "attribute", "exactness"},
-
- SearchableAttributes: []string{
- "title",
- "content",
- "comments",
- },
- DisplayedAttributes: []string{
- "id",
- "title",
- "content",
- "comments",
- },
- FilterableAttributes: []string{
- "repo_id",
- "is_public",
- "is_pull",
- "is_closed",
- "is_archived",
- "label_ids",
- "no_label",
- "milestone_id",
- "project_id",
- "project_board_id",
- "poster_id",
- "assignee_id",
- "mention_ids",
- "reviewed_ids",
- "review_requested_ids",
- "subscriber_ids",
- "updated_unix",
- },
- SortableAttributes: []string{
- "updated_unix",
- "created_unix",
- "deadline_unix",
- "comment_count",
- "id",
- },
- Pagination: &meilisearch.Pagination{
- MaxTotalHits: maxTotalHits,
- },
- }
-
- inner := inner_meilisearch.NewIndexer(url, apiKey, indexerName, issueIndexerLatestVersion, settings)
- indexer := &Indexer{
- inner: inner,
- Indexer: inner,
- }
- return indexer
- }
-
- // Index will save the index data
- func (b *Indexer) Index(_ context.Context, issues ...*internal.IndexerData) error {
- if len(issues) == 0 {
- return nil
- }
- for _, issue := range issues {
- // use default primary key which should be "id"
- _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).AddDocuments(issue, nil)
- if err != nil {
- return err
- }
- }
- // TODO: bulk send index data
- return nil
- }
-
- // Delete deletes indexes by ids
- func (b *Indexer) Delete(_ context.Context, ids ...int64) error {
- if len(ids) == 0 {
- return nil
- }
-
- for _, id := range ids {
- _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).DeleteDocument(strconv.FormatInt(id, 10))
- if err != nil {
- return err
- }
- }
- // TODO: bulk send deletes
- return nil
- }
-
- // Search searches for issues by given conditions.
- // Returns the matching issue IDs
- func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
- query := inner_meilisearch.FilterAnd{}
-
- if len(options.RepoIDs) > 0 {
- q := &inner_meilisearch.FilterOr{}
- q.Or(inner_meilisearch.NewFilterIn("repo_id", options.RepoIDs...))
- if options.AllPublic {
- q.Or(inner_meilisearch.NewFilterEq("is_public", true))
- }
- query.And(q)
- }
-
- if options.IsPull.Has() {
- query.And(inner_meilisearch.NewFilterEq("is_pull", options.IsPull.Value()))
- }
- if options.IsClosed.Has() {
- query.And(inner_meilisearch.NewFilterEq("is_closed", options.IsClosed.Value()))
- }
- if options.IsArchived.Has() {
- query.And(inner_meilisearch.NewFilterEq("is_archived", options.IsArchived.Value()))
- }
-
- if options.NoLabelOnly {
- query.And(inner_meilisearch.NewFilterEq("no_label", true))
- } else {
- if len(options.IncludedLabelIDs) > 0 {
- q := &inner_meilisearch.FilterAnd{}
- for _, labelID := range options.IncludedLabelIDs {
- q.And(inner_meilisearch.NewFilterEq("label_ids", labelID))
- }
- query.And(q)
- } else if len(options.IncludedAnyLabelIDs) > 0 {
- query.And(inner_meilisearch.NewFilterIn("label_ids", options.IncludedAnyLabelIDs...))
- }
- if len(options.ExcludedLabelIDs) > 0 {
- q := &inner_meilisearch.FilterAnd{}
- for _, labelID := range options.ExcludedLabelIDs {
- q.And(inner_meilisearch.NewFilterNot(inner_meilisearch.NewFilterEq("label_ids", labelID)))
- }
- query.And(q)
- }
- }
-
- if len(options.MilestoneIDs) > 0 {
- query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...))
- }
-
- if options.ProjectID.Has() {
- query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value()))
- }
- if options.ProjectColumnID.Has() {
- query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
- }
-
- if options.PosterID != "" {
- // "(none)" becomes 0, it means no poster
- posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
- query.And(inner_meilisearch.NewFilterEq("poster_id", posterIDInt64))
- }
-
- if options.AssigneeID != "" {
- if options.AssigneeID == "(any)" {
- query.And(inner_meilisearch.NewFilterGte("assignee_id", 1))
- } else {
- // "(none)" becomes 0, it means no assignee
- assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
- query.And(inner_meilisearch.NewFilterEq("assignee_id", assigneeIDInt64))
- }
- }
-
- if options.MentionID.Has() {
- query.And(inner_meilisearch.NewFilterEq("mention_ids", options.MentionID.Value()))
- }
-
- if options.ReviewedID.Has() {
- query.And(inner_meilisearch.NewFilterEq("reviewed_ids", options.ReviewedID.Value()))
- }
- if options.ReviewRequestedID.Has() {
- query.And(inner_meilisearch.NewFilterEq("review_requested_ids", options.ReviewRequestedID.Value()))
- }
-
- if options.SubscriberID.Has() {
- query.And(inner_meilisearch.NewFilterEq("subscriber_ids", options.SubscriberID.Value()))
- }
-
- if options.UpdatedAfterUnix.Has() {
- query.And(inner_meilisearch.NewFilterGte("updated_unix", options.UpdatedAfterUnix.Value()))
- }
- if options.UpdatedBeforeUnix.Has() {
- query.And(inner_meilisearch.NewFilterLte("updated_unix", options.UpdatedBeforeUnix.Value()))
- }
-
- if options.SortBy == "" {
- options.SortBy = internal.SortByCreatedAsc
- }
- sortBy := []string{
- parseSortBy(options.SortBy),
- "id:desc",
- }
-
- skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxTotalHits)
-
- counting := limit == 0
- if counting {
- // If set limit to 0, it will be 20 by default, and -1 is not allowed.
- // See https://www.meilisearch.com/docs/reference/api/search#limit
- // So set limit to 1 to make the cost as low as possible, then clear the result before returning.
- limit = 1
- }
-
- keyword := options.Keyword // default to match "words"
- if options.SearchMode == indexer.SearchModeExact {
- // https://www.meilisearch.com/docs/reference/api/search#phrase-search
- keyword = doubleQuoteKeyword(keyword)
- }
-
- searchRes, err := b.inner.Client.Index(b.inner.VersionedIndexName()).Search(keyword, &meilisearch.SearchRequest{
- Filter: query.Statement(),
- Limit: int64(limit),
- Offset: int64(skip),
- Sort: sortBy,
- MatchingStrategy: "all",
- })
- if err != nil {
- return nil, err
- }
-
- if counting {
- searchRes.Hits = nil
- }
-
- hits, err := convertHits(searchRes)
- if err != nil {
- return nil, err
- }
-
- return &internal.SearchResult{
- Total: searchRes.EstimatedTotalHits,
- Hits: hits,
- }, nil
- }
-
- func parseSortBy(sortBy internal.SortBy) string {
- field := strings.TrimPrefix(string(sortBy), "-")
- if strings.HasPrefix(string(sortBy), "-") {
- return field + ":desc"
- }
- return field + ":asc"
- }
-
- func doubleQuoteKeyword(k string) string {
- kp := strings.Split(k, " ")
- parts := 0
- for i := range kp {
- part := strings.Trim(kp[i], "\"")
- if part != "" {
- kp[parts] = fmt.Sprintf(`"%s"`, part)
- parts++
- }
- }
- return strings.Join(kp[:parts], " ")
- }
-
- func convertHits(searchRes *meilisearch.SearchResponse) ([]internal.Match, error) {
- hits := make([]internal.Match, 0, len(searchRes.Hits))
- for _, hit := range searchRes.Hits {
- var issueID int64
- if err := json.Unmarshal(hit["id"], &issueID); err != nil {
- return nil, ErrMalformedResponse
- }
-
- hits = append(hits, internal.Match{
- ID: issueID,
- })
- }
- return hits, nil
- }
|