gitea源码

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. // Copyright 2023 The Gitea Authors. All rights reserved.
  2. // SPDX-License-Identifier: MIT
  3. package meilisearch
  4. import (
  5. "context"
  6. "errors"
  7. "fmt"
  8. "strconv"
  9. "strings"
  10. "code.gitea.io/gitea/modules/indexer"
  11. indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
  12. inner_meilisearch "code.gitea.io/gitea/modules/indexer/internal/meilisearch"
  13. "code.gitea.io/gitea/modules/indexer/issues/internal"
  14. "code.gitea.io/gitea/modules/json"
  15. "github.com/meilisearch/meilisearch-go"
  16. )
  17. const (
  18. issueIndexerLatestVersion = 4
  19. // TODO: make this configurable if necessary
  20. maxTotalHits = 10000
  21. )
  22. // ErrMalformedResponse is never expected as we initialize the indexer ourself and so define the types.
  23. var ErrMalformedResponse = errors.New("meilisearch returned unexpected malformed content")
  24. var _ internal.Indexer = &Indexer{}
  25. // Indexer implements Indexer interface
  26. type Indexer struct {
  27. inner *inner_meilisearch.Indexer
  28. indexer_internal.Indexer // do not composite inner_meilisearch.Indexer directly to avoid exposing too much
  29. }
  30. func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
  31. return indexer.SearchModesExactWords()
  32. }
  33. // NewIndexer creates a new meilisearch indexer
  34. func NewIndexer(url, apiKey, indexerName string) *Indexer {
  35. settings := &meilisearch.Settings{
  36. // The default ranking rules of meilisearch are: ["words", "typo", "proximity", "attribute", "sort", "exactness"]
  37. // So even if we specify the sort order, it could not be respected because the priority of "sort" is so low.
  38. // So we need to specify the ranking rules to make sure the sort order is respected.
  39. // See https://www.meilisearch.com/docs/learn/core_concepts/relevancy
  40. RankingRules: []string{"sort", // make sure "sort" has the highest priority
  41. "words", "typo", "proximity", "attribute", "exactness"},
  42. SearchableAttributes: []string{
  43. "title",
  44. "content",
  45. "comments",
  46. },
  47. DisplayedAttributes: []string{
  48. "id",
  49. "title",
  50. "content",
  51. "comments",
  52. },
  53. FilterableAttributes: []string{
  54. "repo_id",
  55. "is_public",
  56. "is_pull",
  57. "is_closed",
  58. "is_archived",
  59. "label_ids",
  60. "no_label",
  61. "milestone_id",
  62. "project_id",
  63. "project_board_id",
  64. "poster_id",
  65. "assignee_id",
  66. "mention_ids",
  67. "reviewed_ids",
  68. "review_requested_ids",
  69. "subscriber_ids",
  70. "updated_unix",
  71. },
  72. SortableAttributes: []string{
  73. "updated_unix",
  74. "created_unix",
  75. "deadline_unix",
  76. "comment_count",
  77. "id",
  78. },
  79. Pagination: &meilisearch.Pagination{
  80. MaxTotalHits: maxTotalHits,
  81. },
  82. }
  83. inner := inner_meilisearch.NewIndexer(url, apiKey, indexerName, issueIndexerLatestVersion, settings)
  84. indexer := &Indexer{
  85. inner: inner,
  86. Indexer: inner,
  87. }
  88. return indexer
  89. }
  90. // Index will save the index data
  91. func (b *Indexer) Index(_ context.Context, issues ...*internal.IndexerData) error {
  92. if len(issues) == 0 {
  93. return nil
  94. }
  95. for _, issue := range issues {
  96. // use default primary key which should be "id"
  97. _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).AddDocuments(issue, nil)
  98. if err != nil {
  99. return err
  100. }
  101. }
  102. // TODO: bulk send index data
  103. return nil
  104. }
  105. // Delete deletes indexes by ids
  106. func (b *Indexer) Delete(_ context.Context, ids ...int64) error {
  107. if len(ids) == 0 {
  108. return nil
  109. }
  110. for _, id := range ids {
  111. _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).DeleteDocument(strconv.FormatInt(id, 10))
  112. if err != nil {
  113. return err
  114. }
  115. }
  116. // TODO: bulk send deletes
  117. return nil
  118. }
  119. // Search searches for issues by given conditions.
  120. // Returns the matching issue IDs
  121. func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
  122. query := inner_meilisearch.FilterAnd{}
  123. if len(options.RepoIDs) > 0 {
  124. q := &inner_meilisearch.FilterOr{}
  125. q.Or(inner_meilisearch.NewFilterIn("repo_id", options.RepoIDs...))
  126. if options.AllPublic {
  127. q.Or(inner_meilisearch.NewFilterEq("is_public", true))
  128. }
  129. query.And(q)
  130. }
  131. if options.IsPull.Has() {
  132. query.And(inner_meilisearch.NewFilterEq("is_pull", options.IsPull.Value()))
  133. }
  134. if options.IsClosed.Has() {
  135. query.And(inner_meilisearch.NewFilterEq("is_closed", options.IsClosed.Value()))
  136. }
  137. if options.IsArchived.Has() {
  138. query.And(inner_meilisearch.NewFilterEq("is_archived", options.IsArchived.Value()))
  139. }
  140. if options.NoLabelOnly {
  141. query.And(inner_meilisearch.NewFilterEq("no_label", true))
  142. } else {
  143. if len(options.IncludedLabelIDs) > 0 {
  144. q := &inner_meilisearch.FilterAnd{}
  145. for _, labelID := range options.IncludedLabelIDs {
  146. q.And(inner_meilisearch.NewFilterEq("label_ids", labelID))
  147. }
  148. query.And(q)
  149. } else if len(options.IncludedAnyLabelIDs) > 0 {
  150. query.And(inner_meilisearch.NewFilterIn("label_ids", options.IncludedAnyLabelIDs...))
  151. }
  152. if len(options.ExcludedLabelIDs) > 0 {
  153. q := &inner_meilisearch.FilterAnd{}
  154. for _, labelID := range options.ExcludedLabelIDs {
  155. q.And(inner_meilisearch.NewFilterNot(inner_meilisearch.NewFilterEq("label_ids", labelID)))
  156. }
  157. query.And(q)
  158. }
  159. }
  160. if len(options.MilestoneIDs) > 0 {
  161. query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...))
  162. }
  163. if options.ProjectID.Has() {
  164. query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value()))
  165. }
  166. if options.ProjectColumnID.Has() {
  167. query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
  168. }
  169. if options.PosterID != "" {
  170. // "(none)" becomes 0, it means no poster
  171. posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
  172. query.And(inner_meilisearch.NewFilterEq("poster_id", posterIDInt64))
  173. }
  174. if options.AssigneeID != "" {
  175. if options.AssigneeID == "(any)" {
  176. query.And(inner_meilisearch.NewFilterGte("assignee_id", 1))
  177. } else {
  178. // "(none)" becomes 0, it means no assignee
  179. assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
  180. query.And(inner_meilisearch.NewFilterEq("assignee_id", assigneeIDInt64))
  181. }
  182. }
  183. if options.MentionID.Has() {
  184. query.And(inner_meilisearch.NewFilterEq("mention_ids", options.MentionID.Value()))
  185. }
  186. if options.ReviewedID.Has() {
  187. query.And(inner_meilisearch.NewFilterEq("reviewed_ids", options.ReviewedID.Value()))
  188. }
  189. if options.ReviewRequestedID.Has() {
  190. query.And(inner_meilisearch.NewFilterEq("review_requested_ids", options.ReviewRequestedID.Value()))
  191. }
  192. if options.SubscriberID.Has() {
  193. query.And(inner_meilisearch.NewFilterEq("subscriber_ids", options.SubscriberID.Value()))
  194. }
  195. if options.UpdatedAfterUnix.Has() {
  196. query.And(inner_meilisearch.NewFilterGte("updated_unix", options.UpdatedAfterUnix.Value()))
  197. }
  198. if options.UpdatedBeforeUnix.Has() {
  199. query.And(inner_meilisearch.NewFilterLte("updated_unix", options.UpdatedBeforeUnix.Value()))
  200. }
  201. if options.SortBy == "" {
  202. options.SortBy = internal.SortByCreatedAsc
  203. }
  204. sortBy := []string{
  205. parseSortBy(options.SortBy),
  206. "id:desc",
  207. }
  208. skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxTotalHits)
  209. counting := limit == 0
  210. if counting {
  211. // If set limit to 0, it will be 20 by default, and -1 is not allowed.
  212. // See https://www.meilisearch.com/docs/reference/api/search#limit
  213. // So set limit to 1 to make the cost as low as possible, then clear the result before returning.
  214. limit = 1
  215. }
  216. keyword := options.Keyword // default to match "words"
  217. if options.SearchMode == indexer.SearchModeExact {
  218. // https://www.meilisearch.com/docs/reference/api/search#phrase-search
  219. keyword = doubleQuoteKeyword(keyword)
  220. }
  221. searchRes, err := b.inner.Client.Index(b.inner.VersionedIndexName()).Search(keyword, &meilisearch.SearchRequest{
  222. Filter: query.Statement(),
  223. Limit: int64(limit),
  224. Offset: int64(skip),
  225. Sort: sortBy,
  226. MatchingStrategy: "all",
  227. })
  228. if err != nil {
  229. return nil, err
  230. }
  231. if counting {
  232. searchRes.Hits = nil
  233. }
  234. hits, err := convertHits(searchRes)
  235. if err != nil {
  236. return nil, err
  237. }
  238. return &internal.SearchResult{
  239. Total: searchRes.EstimatedTotalHits,
  240. Hits: hits,
  241. }, nil
  242. }
  243. func parseSortBy(sortBy internal.SortBy) string {
  244. field := strings.TrimPrefix(string(sortBy), "-")
  245. if strings.HasPrefix(string(sortBy), "-") {
  246. return field + ":desc"
  247. }
  248. return field + ":asc"
  249. }
  250. func doubleQuoteKeyword(k string) string {
  251. kp := strings.Split(k, " ")
  252. parts := 0
  253. for i := range kp {
  254. part := strings.Trim(kp[i], "\"")
  255. if part != "" {
  256. kp[parts] = fmt.Sprintf(`"%s"`, part)
  257. parts++
  258. }
  259. }
  260. return strings.Join(kp[:parts], " ")
  261. }
  262. func convertHits(searchRes *meilisearch.SearchResponse) ([]internal.Match, error) {
  263. hits := make([]internal.Match, 0, len(searchRes.Hits))
  264. for _, hit := range searchRes.Hits {
  265. var issueID int64
  266. if err := json.Unmarshal(hit["id"], &issueID); err != nil {
  267. return nil, ErrMalformedResponse
  268. }
  269. hits = append(hits, internal.Match{
  270. ID: issueID,
  271. })
  272. }
  273. return hits, nil
  274. }