vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
a6b42433
Keenan Nemetz
8d ago
|
1 | package web |
| 2 | ||
|
4739664b
Keenan Nemetz
7d ago
|
3 | // The user directory: everyone who has an account here, and what they have been |
| 4 | // working on. | |
|
a6b42433
Keenan Nemetz
8d ago
|
5 | // |
| 6 | // Account names on this instance are public, which is why this page lists them | |
| 7 | // and why /u/{name} answers for anybody in the roster. Activity is not public: | |
| 8 | // every count beside a name is limited to the repositories the reader may open, | |
| 9 | // so a private repository's work contributes nothing to a stranger's view. The | |
| 10 | // page says so, once, rather than repeating a caveat on every row. | |
|
4739664b
Keenan Nemetz
7d ago
|
11 | // |
| 12 | // The table is read server-side. The search, the status filter, the order and the | |
| 13 | // page all live in the URL, so a view can be linked and paging cannot quietly | |
| 14 | // change the question. Sorting by a count needs every matched account's summary | |
| 15 | // before the page is cut, which is why the index answers for the whole directory | |
| 16 | // in one grouped query rather than one lookup per row. | |
|
a6b42433
Keenan Nemetz
8d ago
|
17 | |
| 18 | import ( | |
|
4739664b
Keenan Nemetz
7d ago
|
19 | "cmp" |
|
a6b42433
Keenan Nemetz
8d ago
|
20 | "net/http" |
| 21 | "net/url" | |
|
4739664b
Keenan Nemetz
7d ago
|
22 | "sort" |
|
a6b42433
Keenan Nemetz
8d ago
|
23 | "strconv" |
| 24 | "strings" | |
| 25 | "time" | |
| 26 | ||
|
d37d301f
Keenan Nemetz
21h ago
|
27 | "rodent.software/vole/forge/identity" |
| 28 | "rodent.software/vole/forge/index" | |
|
a6b42433
Keenan Nemetz
8d ago
|
29 | ) |
| 30 | ||
| 31 | // peoplePage is how many accounts one page of the directory shows. | |
| 32 | const peoplePage = 50 | |
| 33 | ||
|
4739664b
Keenan Nemetz
7d ago
|
34 | // peopleRow is one account in the table. The counts are the same summary a |
| 35 | // profile reads, so a row and the page it opens tell the same story. | |
|
a6b42433
Keenan Nemetz
8d ago
|
36 | type peopleRow struct { |
| 37 | Name string | |
|
29a795b9
Keenan Nemetz
7d ago
|
38 | Bot bool |
|
a6b42433
Keenan Nemetz
8d ago
|
39 | Active bool |
| 40 | Joined time.Time | |
| 41 | Role string // rendered only for an instance admin | |
| 42 | Summary index.ActorSummary | |
| 43 | } | |
| 44 | ||
|
4739664b
Keenan Nemetz
7d ago
|
45 | // peopleColumn is one sortable header. Href orders by this column: it flips the |
| 46 | // direction when the column is already the order, and takes the column's natural | |
| 47 | // direction otherwise. | |
| 48 | type peopleColumn struct { | |
| 49 | Key string | |
| 50 | Label string | |
| 51 | Href string | |
| 52 | Active bool | |
| 53 | Desc bool | |
| 54 | Numeric bool | |
| 55 | } | |
| 56 | ||
| 57 | // peopleFilter is one chip above the table: a status the directory can be | |
| 58 | // limited to, with the search and the order kept. | |
| 59 | type peopleFilter struct { | |
| 60 | Label string | |
| 61 | Href string | |
| 62 | Active bool | |
| 63 | } | |
| 64 | ||
|
a6b42433
Keenan Nemetz
8d ago
|
65 | type peopleView struct { |
| 66 | Page | |
|
4739664b
Keenan Nemetz
7d ago
|
67 | Query string |
| 68 | Status string | |
| 69 | Columns []peopleColumn | |
| 70 | Filters []peopleFilter | |
| 71 | Rows []peopleRow | |
| 72 | Pager pager | |
|
a6b42433
Keenan Nemetz
8d ago
|
73 | Total int |
| 74 | Filtered bool | |
|
4739664b
Keenan Nemetz
7d ago
|
75 | // Sort and Dir are what the search form has to carry so a search does not |
| 76 | // reset the order. They are empty for the default order, which keeps the | |
| 77 | // common URL short. | |
| 78 | Sort string | |
| 79 | Dir string | |
| 80 | ShowRole bool | |
| 81 | } | |
| 82 | ||
| 83 | // peopleSort is the order the table is read in. | |
| 84 | type peopleSort struct { | |
| 85 | key string | |
| 86 | desc bool | |
| 87 | } | |
| 88 | ||
| 89 | // peopleSortable is every column the directory can be ordered by. | |
| 90 | const ( | |
| 91 | sortName = "name" | |
| 92 | sortRepos = "repos" | |
| 93 | sortIssues = "issues" | |
| 94 | sortChanges = "changes" | |
| 95 | sortReviews = "reviews" | |
| 96 | sortJoined = "joined" | |
| 97 | ) | |
| 98 | ||
| 99 | func parsePeopleSort(key, dir string) peopleSort { | |
| 100 | o := peopleSort{key: sortName} | |
| 101 | switch key { | |
| 102 | case sortName, sortRepos, sortIssues, sortChanges, sortReviews, sortJoined: | |
| 103 | o.key = key | |
| 104 | } | |
| 105 | switch dir { | |
| 106 | case "asc": | |
| 107 | o.desc = false | |
| 108 | case "desc": | |
| 109 | o.desc = true | |
| 110 | default: | |
| 111 | o.desc = peopleSortDescFirst(o.key) | |
| 112 | } | |
| 113 | return o | |
| 114 | } | |
| 115 | ||
| 116 | // peopleSortDescFirst is the direction a column takes the first time it is | |
| 117 | // chosen: a count is most useful with the largest first, a joining date with the | |
| 118 | // newest first, and a name from A to Z. | |
| 119 | func peopleSortDescFirst(key string) bool { | |
| 120 | return key != sortName | |
| 121 | } | |
| 122 | ||
| 123 | // peopleStatus narrows the table to active or retired accounts. An unknown value | |
| 124 | // is the whole directory rather than an error, so a stale link still shows | |
| 125 | // something. | |
| 126 | func parsePeopleStatus(value string) string { | |
| 127 | switch value { | |
| 128 | case "active", "retired": | |
| 129 | return value | |
| 130 | } | |
| 131 | return "" | |
| 132 | } | |
| 133 | ||
| 134 | // peopleURL builds a directory link. The first page and the default order are | |
| 135 | // left out, so the URL a reader copies is the short one. | |
| 136 | func peopleURL(query, status string, order peopleSort, page int) string { | |
| 137 | v := url.Values{} | |
| 138 | if query != "" { | |
| 139 | v.Set("q", query) | |
| 140 | } | |
| 141 | if status != "" { | |
| 142 | v.Set("status", status) | |
| 143 | } | |
| 144 | if order.key != sortName || order.desc { | |
| 145 | v.Set("sort", order.key) | |
| 146 | if order.desc { | |
| 147 | v.Set("dir", "desc") | |
| 148 | } else { | |
| 149 | v.Set("dir", "asc") | |
| 150 | } | |
| 151 | } | |
| 152 | if page > 1 { | |
| 153 | v.Set("page", strconv.Itoa(page)) | |
| 154 | } | |
| 155 | if q := v.Encode(); q != "" { | |
|
ed03b3d1
Keenan Nemetz
8h ago
|
156 | return "/-/u?" + q |
|
4739664b
Keenan Nemetz
7d ago
|
157 | } |
|
ed03b3d1
Keenan Nemetz
8h ago
|
158 | return "/-/u" |
|
4739664b
Keenan Nemetz
7d ago
|
159 | } |
| 160 | ||
| 161 | // peopleColumns builds the header row: one column per sortable field, each with | |
| 162 | // the link that orders by it. | |
| 163 | func peopleColumns(query, status string, order peopleSort) []peopleColumn { | |
| 164 | defs := []struct { | |
| 165 | key string | |
| 166 | label string | |
| 167 | numeric bool | |
| 168 | }{ | |
| 169 | {sortName, "User", false}, | |
| 170 | {sortRepos, "Repositories", true}, | |
| 171 | {sortIssues, "Issues", true}, | |
| 172 | {sortChanges, "Changes", true}, | |
| 173 | {sortReviews, "Reviews", true}, | |
| 174 | {sortJoined, "Joined", false}, | |
| 175 | } | |
| 176 | cols := make([]peopleColumn, 0, len(defs)) | |
| 177 | for _, d := range defs { | |
| 178 | next := order | |
| 179 | if order.key == d.key { | |
| 180 | next.desc = !order.desc | |
| 181 | } else { | |
| 182 | next.key = d.key | |
| 183 | next.desc = peopleSortDescFirst(d.key) | |
| 184 | } | |
| 185 | cols = append(cols, peopleColumn{ | |
| 186 | Key: d.key, Label: d.label, Numeric: d.numeric, | |
| 187 | Href: peopleURL(query, status, next, 1), | |
| 188 | Active: order.key == d.key, Desc: order.desc, | |
| 189 | }) | |
| 190 | } | |
| 191 | return cols | |
| 192 | } | |
| 193 | ||
| 194 | // peopleFilters builds the status chips, each keeping the search and the order. | |
| 195 | func peopleFilters(query string, order peopleSort, status string) []peopleFilter { | |
| 196 | defs := []struct{ value, label string }{ | |
| 197 | {"", "Everyone"}, | |
| 198 | {"active", "Active"}, | |
| 199 | {"retired", "No longer active"}, | |
| 200 | } | |
| 201 | out := make([]peopleFilter, 0, len(defs)) | |
| 202 | for _, d := range defs { | |
| 203 | out = append(out, peopleFilter{ | |
| 204 | Label: d.label, | |
| 205 | Href: peopleURL(query, d.value, order, 1), | |
| 206 | Active: d.value == status, | |
| 207 | }) | |
| 208 | } | |
| 209 | return out | |
|
a6b42433
Keenan Nemetz
8d ago
|
210 | } |
| 211 | ||
| 212 | // PeoplePage serves GET /u, the directory of accounts. | |
| 213 | func (w *Web) PeoplePage(rw http.ResponseWriter, r *http.Request) { | |
| 214 | if w.Ident == nil { | |
| 215 | w.notFound(rw, r) | |
| 216 | return | |
| 217 | } | |
| 218 | query := strings.TrimSpace(r.URL.Query().Get("q")) | |
|
4739664b
Keenan Nemetz
7d ago
|
219 | status := parsePeopleStatus(r.URL.Query().Get("status")) |
| 220 | order := parsePeopleSort(r.URL.Query().Get("sort"), r.URL.Query().Get("dir")) | |
|
a6b42433
Keenan Nemetz
8d ago
|
221 | number, _ := strconv.Atoi(r.URL.Query().Get("page")) |
| 222 | ||
|
4739664b
Keenan Nemetz
7d ago
|
223 | // All reads the roster once. The filter and the order are applied here rather |
| 224 | // than in a store because the roster is already in memory as a snapshot. | |
|
a6b42433
Keenan Nemetz
8d ago
|
225 | accounts := w.Ident.All() |
| 226 | needle := strings.ToLower(query) | |
|
4739664b
Keenan Nemetz
7d ago
|
227 | matched := make([]*identity.Account, 0, len(accounts)) |
|
a6b42433
Keenan Nemetz
8d ago
|
228 | for _, acct := range accounts { |
|
4739664b
Keenan Nemetz
7d ago
|
229 | if status != "" && acct.Status != status { |
| 230 | continue | |
| 231 | } | |
|
a6b42433
Keenan Nemetz
8d ago
|
232 | if needle != "" && !strings.Contains(strings.ToLower(acct.Name), needle) { |
| 233 | continue | |
| 234 | } | |
| 235 | matched = append(matched, acct) | |
| 236 | } | |
| 237 | ||
|
4739664b
Keenan Nemetz
7d ago
|
238 | // The counts are read for every matched account, not only the visible page: |
| 239 | // the order can be by a count, and a page cannot be sorted by numbers the row | |
| 240 | // does not have. | |
|
a6b42433
Keenan Nemetz
8d ago
|
241 | summaries := map[string]index.ActorSummary{} |
|
4739664b
Keenan Nemetz
7d ago
|
242 | if w.Index != nil && len(matched) > 0 { |
|
a6b42433
Keenan Nemetz
8d ago
|
243 | entries, err := w.visibleRepos(r, "") |
| 244 | if err != nil { | |
| 245 | w.serverError(rw, r, "index unavailable: "+err.Error()) | |
| 246 | return | |
| 247 | } | |
| 248 | repoIDs, _ := w.visibleIndexedRepos(memoOf(r), entries) | |
|
4739664b
Keenan Nemetz
7d ago
|
249 | if summaries, err = w.Index.ActorSummaries(repoIDs); err != nil { |
|
a6b42433
Keenan Nemetz
8d ago
|
250 | w.serverError(rw, r, "cannot read the directory: "+err.Error()) |
| 251 | return | |
| 252 | } | |
| 253 | } | |
| 254 | ||
|
c14588ef
Keenan Nemetz
7d ago
|
255 | page := w.page(r, "Users") |
|
a6b42433
Keenan Nemetz
8d ago
|
256 | page.NoIndex = true |
|
4739664b
Keenan Nemetz
7d ago
|
257 | rows := make([]peopleRow, 0, len(matched)) |
| 258 | for _, acct := range matched { | |
|
a6b42433
Keenan Nemetz
8d ago
|
259 | row := peopleRow{ |
|
29a795b9
Keenan Nemetz
7d ago
|
260 | Name: acct.Name, Bot: acct.Bot(), Active: acct.Active(), Joined: acct.Created, |
|
a6b42433
Keenan Nemetz
8d ago
|
261 | Summary: summaries[acct.Name], |
| 262 | } | |
| 263 | if page.InstanceAdmin { | |
| 264 | row.Role = acct.Role.String() | |
| 265 | } | |
|
4739664b
Keenan Nemetz
7d ago
|
266 | rows = append(rows, row) |
| 267 | } | |
| 268 | sortPeople(rows, order) | |
| 269 | ||
| 270 | p := newPager(len(rows), peoplePage, number, func(n int) string { | |
| 271 | return peopleURL(query, status, order, n) | |
| 272 | }) | |
| 273 | p.Label = "User pages" | |
| 274 | from, to := p.rows() | |
| 275 | ||
| 276 | view := peopleView{ | |
| 277 | Page: page, Query: query, Status: status, Pager: p, | |
| 278 | Total: len(rows), Filtered: query != "" || status != "", | |
| 279 | Columns: peopleColumns(query, status, order), | |
| 280 | Filters: peopleFilters(query, order, status), | |
| 281 | Rows: rows[from:to], ShowRole: page.InstanceAdmin, | |
| 282 | } | |
| 283 | if order.key != sortName || order.desc { | |
| 284 | view.Sort = order.key | |
| 285 | view.Dir = "asc" | |
| 286 | if order.desc { | |
| 287 | view.Dir = "desc" | |
|
a6b42433
Keenan Nemetz
8d ago
|
288 | } |
| 289 | } | |
| 290 | w.render(rw, "people", view, 0) | |
| 291 | } | |
| 292 | ||
|
4739664b
Keenan Nemetz
7d ago
|
293 | // sortPeople orders the rows. Equal values fall back to the name, so the table |
| 294 | // does not shuffle between requests that ask the same question. | |
| 295 | func sortPeople(rows []peopleRow, order peopleSort) { | |
| 296 | sort.SliceStable(rows, func(i, j int) bool { | |
| 297 | c := comparePeople(order.key, rows[i], rows[j]) | |
| 298 | if c == 0 { | |
| 299 | return rows[i].Name < rows[j].Name | |
|
a6b42433
Keenan Nemetz
8d ago
|
300 | } |
|
4739664b
Keenan Nemetz
7d ago
|
301 | if order.desc { |
| 302 | return c > 0 | |
|
a6b42433
Keenan Nemetz
8d ago
|
303 | } |
|
4739664b
Keenan Nemetz
7d ago
|
304 | return c < 0 |
| 305 | }) | |
| 306 | } | |
| 307 | ||
| 308 | func comparePeople(key string, a, b peopleRow) int { | |
| 309 | switch key { | |
| 310 | case sortRepos: | |
| 311 | return cmp.Compare(a.Summary.Repos, b.Summary.Repos) | |
| 312 | case sortIssues: | |
| 313 | return cmp.Compare(a.Summary.Issues, b.Summary.Issues) | |
| 314 | case sortChanges: | |
| 315 | return cmp.Compare(a.Summary.Changes, b.Summary.Changes) | |
| 316 | case sortReviews: | |
| 317 | return cmp.Compare(a.Summary.Reviews, b.Summary.Reviews) | |
| 318 | case sortJoined: | |
| 319 | return a.Joined.Compare(b.Joined) | |
|
a6b42433
Keenan Nemetz
8d ago
|
320 | } |
|
4739664b
Keenan Nemetz
7d ago
|
321 | return strings.Compare(a.Name, b.Name) |
|
a6b42433
Keenan Nemetz
8d ago
|
322 | } |