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