From c21b3e2c1f51cc9c1929814c12cf3c001aca3320 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Tue, 31 Mar 2026 10:40:15 +0000 Subject: [PATCH] appview/state: add /search handler Signed-off-by: oppiliappan --- appview/models/search.go | 4 ++++ appview/pages/pages.go | 15 +++++++++++++++ appview/state/router.go | 2 ++ appview/state/search.go | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 file(s) changed, 181 insertion(s)(+), 0 deletion(s)(-) diff --git a/appview/models/search.go b/appview/models/search.go --- a/appview/models/search.go +++ b/appview/models/search.go @@ -67,6 +67,10 @@ NegatedKeywords []string NegatedPhrases []string NegatedTopics []string + // Sort configuration + SortField string + SortDesc bool + Page pagination.Page } diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1590,6 +1590,21 @@ func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { return p.execute("strings/string", w, params) } +type SearchReposParams struct { + LoggedInUser *oauth.MultiAccountUser + Repos []models.Repo + Page pagination.Page + ResultCount int + FilterQuery string + SortParam string + TimeTaken time.Duration + DocCount int64 +} + +func (p *Pages) SearchRepos(w io.Writer, params SearchReposParams) error { + return p.execute("search/search", w, params) +} + func (p *Pages) Home(w io.Writer, params TimelineParams) error { return p.execute("timeline/home", w, params) } diff --git a/appview/state/router.go b/appview/state/router.go --- a/appview/state/router.go +++ b/appview/state/router.go @@ -156,6 +156,8 @@ r.Get("/login", s.Login) r.Post("/login", s.Login) r.Post("/logout", s.Logout) + r.With(middleware.Paginate).Get("/search", s.Search) + r.Post("/account/switch", s.SwitchAccount) r.With(middleware.AuthMiddleware(s.oauth)).Delete("/account/{did}", s.RemoveAccount) diff --git a/appview/state/search.go b/appview/state/search.go new file mode 100644 --- /dev/null +++ b/appview/state/search.go @@ -0,0 +1,160 @@ +package state + +import ( + "net/http" + "strings" + "time" + + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pages" + "tangled.org/core/appview/pagination" + "tangled.org/core/appview/searchquery" + "tangled.org/core/orm" +) + +func (s *State) Search(w http.ResponseWriter, r *http.Request) { + l := s.logger.With("handler", "Search") + + params := r.URL.Query() + page := pagination.FromContext(r.Context()) + + query := searchquery.Parse(params.Get("q")) + + sortParam := params.Get("sort") + sortField, sortDesc := parseSortParam(sortParam) + + var language string + if lang := query.Get("language"); lang != nil { + language = *lang + } + + tf := searchquery.ExtractTextFilters(query) + + searchOpts := models.RepoSearchOptions{ + Keywords: tf.Keywords, + Phrases: tf.Phrases, + NegatedKeywords: tf.NegatedKeywords, + NegatedPhrases: tf.NegatedPhrases, + Language: language, + SortField: sortField, + SortDesc: sortDesc, + Page: page, + } + + var repos []models.Repo + var err error + var resultCount int + var searchDuration time.Duration + var docCount int64 + + if searchOpts.HasSearchFilters() || sortField != "" { + res, err := s.indexer.Repos.Search(r.Context(), searchOpts) + if err != nil { + l.Error("failed to search repos", "err", err) + s.pages.Error500(w) + return + } + + searchDuration = res.Duration + + if len(res.Hits) > 0 { + repos, err = db.GetRepos(s.db, orm.FilterIn("id", res.Hits)) + if err != nil { + l.Error("failed to get repos by IDs", "err", err) + s.pages.Error500(w) + return + } + + // sort repos to match search result order (by relevance) + repoMap := make(map[int64]models.Repo, len(repos)) + for _, repo := range repos { + repoMap[repo.Id] = repo + } + repos = make([]models.Repo, 0, len(res.Hits)) + for _, id := range res.Hits { + if repo, ok := repoMap[id]; ok { + repos = append(repos, repo) + } + } + } + resultCount = int(res.Total) + + dc, err := (s.indexer.Repos.TotalDocCount()) + if err != nil { + l.Error("failed to get total doc count", "err", err) + } + docCount = int64(dc) + + } else { + repos, err = db.GetReposPaginated( + s.db, + page, + ) + if err != nil { + l.Error("failed to get repos", "err", err) + s.pages.Error500(w) + return + } + + rc, err := db.CountRepos( + s.db, + ) + if err != nil { + l.Error("failed to count repos", "err", err) + s.pages.Error500(w) + return + } + + resultCount = int(rc) + docCount = int64(rc) + } + + err = s.pages.SearchRepos(w, pages.SearchReposParams{ + LoggedInUser: s.oauth.GetMultiAccountUser(r), + Repos: repos, + Page: page, + FilterQuery: query.String(), + SortParam: sortParam, + TimeTaken: searchDuration, + ResultCount: resultCount, + DocCount: docCount, + }) + if err != nil { + l.Error("failed to render page", "err", err) + } +} + +// parseSortParam parses sort parameter like "stars-desc" or "created-asc" +func parseSortParam(sortParam string) (string, bool) { + defaultSort := func() (string, bool) { return "relevance", true } + + // no sort param supplied, just go default + if sortParam == "" { + return defaultSort() + } + + parts := strings.Split(sortParam, "-") + if len(parts) != 2 { + return defaultSort() + } + + field := parts[0] + desc := parts[1] == "desc" + + // validate field + validFields := map[string]bool{ + "relevance": true, + "created": true, + "stars": true, + "issues": true, + "pulls": true, + } + + // invalid fields, just go default + if !validFields[field] { + return defaultSort() + } + + return field, desc +} -- tangled.sh