Monorepo for Tangled
tangled.org
1package state
2
3import (
4 "cmp"
5 "net/http"
6 "slices"
7 "strings"
8 "time"
9
10 "github.com/posthog/posthog-go"
11 "tangled.org/core/appview/db"
12 "tangled.org/core/appview/models"
13 "tangled.org/core/appview/pages"
14 "tangled.org/core/appview/pagination"
15 "tangled.org/core/appview/searchquery"
16 "tangled.org/core/orm"
17)
18
19func (s *State) Search(w http.ResponseWriter, r *http.Request) {
20 l := s.logger.With("handler", "Search")
21
22 params := r.URL.Query()
23 page := pagination.FromContext(r.Context())
24
25 query := searchquery.Parse(params.Get("q"))
26
27 sortParam := params.Get("sort")
28 sortField, sortDesc := parseSortParam(sortParam)
29
30 var language string
31 if lang := cmp.Or(query.Get("language"), query.Get("lang")); lang != nil {
32 language = *lang
33 }
34
35 tf := searchquery.ExtractTextFilters(query)
36
37 searchOpts := models.RepoSearchOptions{
38 Keywords: tf.Keywords,
39 Phrases: tf.Phrases,
40 NegatedKeywords: tf.NegatedKeywords,
41 NegatedPhrases: tf.NegatedPhrases,
42 Language: language,
43 SortField: sortField,
44 SortDesc: sortDesc,
45 Page: page,
46 }
47
48 var repos []models.Repo
49 var err error
50 var resultCount int
51 var searchDuration time.Duration
52 var docCount int64
53 method := "bleve"
54
55 if searchOpts.HasSearchFilters() || sortParam != "" {
56 res, err := s.indexer.Repos.Search(r.Context(), searchOpts)
57 if err != nil {
58 l.Error("failed to search repos", "err", err)
59 s.pages.Error500(w)
60 return
61 }
62
63 searchDuration = res.Duration
64
65 if len(res.Hits) > 0 {
66 repos, err = db.GetRepos(s.db, orm.FilterIn("id", res.Hits))
67 if err != nil {
68 l.Error("failed to get repos by IDs", "err", err)
69 s.pages.Error500(w)
70 return
71 }
72
73 hitIdx := make(map[int64]int, len(res.Hits))
74 for i, id := range res.Hits {
75 hitIdx[id] = i
76 }
77 slices.SortFunc(repos, func(a, b models.Repo) int {
78 return cmp.Compare(hitIdx[a.Id], hitIdx[b.Id])
79 })
80 }
81 resultCount = int(res.Total)
82
83 dc, err := (s.indexer.Repos.TotalDocCount())
84 if err != nil {
85 l.Error("failed to get total doc count", "err", err)
86 }
87 docCount = int64(dc)
88
89 } else {
90 method = "db"
91 repos, err = db.GetReposPaginated(
92 s.db,
93 page,
94 )
95 if err != nil {
96 l.Error("failed to get repos", "err", err)
97 s.pages.Error500(w)
98 return
99 }
100
101 rc, err := db.CountRepos(
102 s.db,
103 )
104 if err != nil {
105 l.Error("failed to count repos", "err", err)
106 s.pages.Error500(w)
107 return
108 }
109
110 resultCount = int(rc)
111 docCount = int64(rc)
112 }
113
114 l.Info(
115 "RepoSearch",
116 "method", method,
117 "resultCount", resultCount,
118 "docCount", docCount,
119 "time", searchDuration,
120 "filterQuery", query.String(),
121 "sortParam", sortParam,
122 )
123
124 if !s.config.Core.Dev && query.String() != "" {
125 distinctId := s.oauth.GetDid(r)
126 if distinctId == "" {
127 distinctId = "anonymous"
128 }
129 go func() {
130 if err := s.posthog.Enqueue(posthog.Capture{
131 DistinctId: distinctId,
132 Event: "search",
133 Properties: posthog.Properties{
134 "query": query.String(),
135 "result_count": resultCount,
136 "method": method,
137 },
138 }); err != nil {
139 l.Error("failed to enqueue posthog event", "err", err)
140 }
141 }()
142 }
143
144 err = s.pages.SearchRepos(w, pages.SearchReposParams{
145 LoggedInUser: s.oauth.GetMultiAccountUser(r),
146 Repos: repos,
147 Page: page,
148 FilterQuery: query.String(),
149 SortParam: sortParam,
150 TimeTaken: searchDuration,
151 ResultCount: resultCount,
152 DocCount: docCount,
153 })
154 if err != nil {
155 l.Error("failed to render page", "err", err)
156 }
157}
158
159func (s *State) SearchQuick(w http.ResponseWriter, r *http.Request) {
160 s.searchQuick(w, r, false)
161}
162
163func (s *State) SearchQuickMobile(w http.ResponseWriter, r *http.Request) {
164 s.searchQuick(w, r, true)
165}
166
167func (s *State) searchQuick(w http.ResponseWriter, r *http.Request, mobile bool) {
168 rawQuery := r.URL.Query().Get("q")
169 if rawQuery == "" {
170 w.WriteHeader(http.StatusOK)
171 return
172 }
173
174 const pageSize = 5
175
176 query := searchquery.Parse(rawQuery)
177 tf := searchquery.ExtractTextFilters(query)
178
179 searchOpts := models.RepoSearchOptions{
180 Keywords: tf.Keywords,
181 Phrases: tf.Phrases,
182 NegatedKeywords: tf.NegatedKeywords,
183 NegatedPhrases: tf.NegatedPhrases,
184 Page: pagination.Page{Limit: pageSize},
185 }
186
187 var repos []models.Repo
188 var total int
189
190 if searchOpts.HasSearchFilters() {
191 res, err := s.indexer.Repos.Search(r.Context(), searchOpts)
192 if err != nil {
193 s.logger.Error("failed quick search", "err", err)
194 http.Error(w, "search failed", http.StatusInternalServerError)
195 return
196 }
197 total = int(res.Total)
198 if len(res.Hits) > 0 {
199 repos, err = db.GetRepos(s.db, orm.FilterIn("id", res.Hits))
200 if err != nil {
201 s.logger.Error("failed to get repos for quick search", "err", err)
202 http.Error(w, "search failed", http.StatusInternalServerError)
203 return
204 }
205 hitIdx := make(map[int64]int, len(res.Hits))
206 for i, id := range res.Hits {
207 hitIdx[id] = i
208 }
209 slices.SortFunc(repos, func(a, b models.Repo) int {
210 return cmp.Compare(hitIdx[a.Id], hitIdx[b.Id])
211 })
212 }
213 }
214
215 params := pages.SearchQuickParams{
216 Repos: repos,
217 Query: rawQuery,
218 Total: total,
219 }
220
221 render := s.pages.SearchQuick
222 if mobile {
223 render = s.pages.SearchQuickMobile
224 }
225 if err := render(w, params); err != nil {
226 s.logger.Error("failed to render quick search", "err", err)
227 }
228}
229
230// parseSortParam parses sort parameter like "stars-desc" or "created-asc"
231func parseSortParam(sortParam string) (string, bool) {
232 defaultSort := func() (string, bool) { return "relevance", true }
233
234 // no sort param supplied, just go default
235 if sortParam == "" {
236 return defaultSort()
237 }
238
239 parts := strings.Split(sortParam, "-")
240 if len(parts) != 2 {
241 return defaultSort()
242 }
243
244 field := parts[0]
245 desc := parts[1] == "desc"
246
247 // validate field
248 validFields := map[string]bool{
249 "relevance": true,
250 "created": true,
251 "stars": true,
252 "issues": true,
253 "pulls": true,
254 }
255
256 // invalid fields, just go default
257 if !validFields[field] {
258 return defaultSort()
259 }
260
261 return field, desc
262}