render.gno
19.86 Kb · 566 lines
1package kourt
2import (
3 "strconv"
4 "strings"
5 sanitize "gno.land/p/nt/markdown/sanitize/v0"
6)
7func Render(path string) string {
8 return withSiteBanner(render(path), path)
9}
10func render(path string) string {
11 path = strings.Trim(path, "/")
12 if path == "" {
13 return renderDirectory()
14 }
15 parts := strings.SplitN(path, "/", 4)
16 if len(parts) == 1 {
17 if parts[0] == helpPath {
18 return renderHelp()
19 }
20 if parts[0] == adminParamsPath {
21 return renderAdminParams()
22 }
23 return renderCourt(parts[0])
24 }
25 if parts[1] == "mod" {
26 return renderModLog(parts[0])
27 }
28 if parts[1] == electionPath {
29 if len(parts) != 2 {
30 return "## Not found\nThe ballot is /<court>/election."
31 }
32 return renderElection(parts[0])
33 }
34 if parts[1] == "folder" {
35 if len(parts) != 3 {
36 return "## Not found\nA folder is /<court>/folder/<id>."
37 }
38 fid, ferr := strconv.ParseUint(parts[2], 10, 64)
39 if ferr != nil {
40 return "## Not found\nA folder id must be a number."
41 }
42 return renderFolderPage(parts[0], fid)
43 }
44 if parts[1] == "me" {
45 if len(parts) == 3 {
46 return renderStanding(parts[0], parts[2])
47 }
48 return renderStandingIndex(parts[0])
49 }
50 id, err := strconv.ParseUint(parts[1], 10, 64)
51 if err != nil {
52 return "## Not found\nA claim id must be a number."
53 }
54 if len(parts) >= 3 && parts[2] == "board" {
55 if len(parts) == 4 {
56 if parts[3] == "top" {
57 return renderBoardTop(parts[0], id)
58 }
59 if parts[3] == "hidden" {
60 return renderBoardHidden(parts[0], id)
61 }
62 return renderBoardRow(parts[0], id, parts[3])
63 }
64 return renderBoardPage(parts[0], id)
65 }
66 if len(parts) == 4 {
67 return "## Not found\nThat is not a page."
68 }
69 if len(parts) == 3 {
70 return renderPositions(parts[0], id, parts[2])
71 }
72 return renderClaim(parts[0], id)
73}
74func renderDirectory() string {
75 b := strings.Builder{}
76 b.WriteString("# " + platformName + "\n\nLet Truth be told.\n\n")
77 b.WriteString("Stake on claims of fact. Your principal always returns 1×.\n\n")
78 b.WriteString(helpLink + "\n\n")
79 afterHeader := b.Len()
80 writeTier(&b, "Featured", tierFeatured)
81 writeListedByBurn(&b)
82 if b.Len() == afterHeader {
83 b.WriteString("_No courts yet._\n")
84 }
85 return b.String()
86}
87func writeListedByBurn(b *strings.Builder) {
88 slugs := listedPage("burn", 0, renderPageSize)
89 if len(slugs) == 0 {
90 return
91 }
92 b.WriteString("## Courts\n\n")
93 shown := 0
94 for _, s := range slugs {
95 c := mustCourt(s)
96 if c.tier != tierListed {
97 continue
98 }
99 shown++
100 b.WriteString("- [" + courtNameFor(c) + "](/r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:" + s + ") · " +
101 qualifiedSymbol(c) + " — " + strconv.FormatUint(c.nextID, 10) + " claims\n")
102 }
103 if total := len(ListByTier(int(tierListed))); total > shown {
104 b.WriteString("- _…and " + strconv.Itoa(total-shown) +
105 " more; open any court directly at /r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:<slug>_\n")
106 }
107 b.WriteString("\n")
108}
109const renderPageSize = 50
110func writeTier(b *strings.Builder, heading string, tier uint8) {
111 slugs := ListByTier(int(tier))
112 if len(slugs) == 0 {
113 return
114 }
115 b.WriteString("## " + heading + "\n\n")
116 shown := len(slugs)
117 if shown > renderPageSize {
118 shown = renderPageSize
119 }
120 for _, s := range slugs[:shown] {
121 c := mustCourt(s)
122 b.WriteString("- [" + courtNameFor(c) + "](/r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:" + s + ") · " +
123 qualifiedSymbol(c) + " — " + strconv.FormatUint(c.nextID, 10) + " claims\n")
124 }
125 if len(slugs) > shown {
126 b.WriteString("- _…and " + strconv.Itoa(len(slugs)-shown) +
127 " more; open any court directly at /r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:<slug>_\n")
128 }
129 b.WriteString("\n")
130}
131func renderCourt(slug string) string {
132 if !Exists(slug) {
133 return "## Not found\nNo court by that slug."
134 }
135 c := mustCourt(slug)
136 b := strings.Builder{}
137 b.WriteString("# " + courtNameFor(c) + "\n\n")
138 if c.tier == tierHidden {
139 b.WriteString("> **Delisted by the global DAO.** This court appears in no " +
140 "listing and is reachable only by direct link. Nothing else changes: " +
141 "its claims still stake, answer, settle and pay out as usual.\n\n")
142 }
143 if d := courtDescFor(c); d != "" {
144 b.WriteString(d + "\n\n")
145 }
146 writeFolderIndex(&b, c, slug)
147 b.WriteString("## Claims\n\n")
148 if c.nextID == 0 {
149 b.WriteString("_No claims yet._\n\n")
150 writeCourtCoin(&b, c)
151 return b.String()
152 }
153 lo := uint64(1)
154 if c.nextID > renderPageSize {
155 lo = c.nextID - renderPageSize + 1
156 }
157 for id := c.nextID; id >= lo; id-- {
158 cs := mustClaim(c, id)
159 if HiddenFromListing(slug, id) {
160 continue
161 }
162 b.WriteString("- [" + claimTitleFor(c, cs) + "](/r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:" + slug + "/" +
163 strconv.FormatUint(id, 10) + ") — " + claimStatus(cs) + "\n")
164 }
165 if lo > 1 {
166 b.WriteString("- _…and " + strconv.FormatUint(lo-1, 10) +
167 " older; open any by id at /r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:" + slug + "/<id>_\n")
168 }
169 b.WriteString("\n")
170 writeStrip(&b, slug)
171 writePending(&b, slug)
172 writeCourtCoin(&b, c)
173 b.WriteString("[Moderation log](/r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:" + slug + "/mod)\n")
174 return b.String()
175}
176func writeCourtCoin(b *strings.Builder, c *Court) {
177 b.WriteString("## This court's coin\n\n")
178 b.WriteString("- " + qualifiedSymbol(c) +
179 " — this court's own coin, not interchangeable with any other court's\n")
180 b.WriteString("- price: " + strconv.FormatInt(c.crv.Price(c.minted), 10) +
181 " ugnot per coin — buying burns the GNOT, there is no sell side, " +
182 "and the price only rises\n")
183 b.WriteString("- in circulation: " + strconv.FormatInt(c.coin.TotalSupply(), 10) +
184 " · paid out as rewards so far: " + strconv.FormatInt(c.emittedTotal, 10) + "\n")
185 b.WriteString("- reward reservoir: " + strconv.FormatInt(c.reservoirR(), 10) +
186 " waiting to be earned · " + strconv.FormatInt(c.seniorOwed, 10) +
187 " already owed to earlier earners\n\n")
188 b.WriteString(helpLink + "\n\n")
189}
190func renderClaim(slug string, id uint64) string {
191 if !Exists(slug) {
192 return "## Not found\nNo court by that slug."
193 }
194 c := mustCourt(slug)
195 if id == 0 || id > c.nextID {
196 return "## Not found\nNo claim by that id."
197 }
198 cs := mustClaim(c, id)
199 b := strings.Builder{}
200 b.WriteString("# " + claimTitleFor(c, cs) + "\n\n")
201 if body := claimBodyQuoted(c, cs); body != "" {
202 b.WriteString(body)
203 }
204 writeClaimMedia(&b, c, cs)
205 b.WriteString(hideBanner(c, cs))
206 writeRelations(&b, c, id)
207 writeAppeal(&b, c, cs)
208 if cs.seeded {
209 b.WriteString("_Seeded by a moderator to start this court; the author earns nothing from it._\n\n")
210 }
211 b.WriteString("## Signal\n\n")
212 if cs.frozenAt != 0 {
213 fTotal := cs.yesStakeAtFreeze + cs.noStakeAtFreeze
214 b.WriteString("- staked when answered: " + strconv.FormatInt(fTotal, 10) +
215 " " + qualifiedSymbol(c) + " (YES " + strconv.FormatInt(cs.yesStakeAtFreeze, 10) +
216 " / NO " + strconv.FormatInt(cs.noStakeAtFreeze, 10) + ")\n")
217 if fTotal > 0 {
218 b.WriteString("- lean when answered: " + pct(cs.yesStakeAtFreeze, fTotal) + " YES\n")
219 }
220 if live := cs.yesStake + cs.noStake; live > 0 {
221 b.WriteString("- still unwithdrawn: " + strconv.FormatInt(live, 10) +
222 " " + qualifiedSymbol(c) + "\n")
223 }
224 } else {
225 total := cs.yesStake + cs.noStake
226 b.WriteString("- staked now: " + strconv.FormatInt(total, 10) +
227 " " + qualifiedSymbol(c) + " (YES " + strconv.FormatInt(cs.yesStake, 10) +
228 " / NO " + strconv.FormatInt(cs.noStake, 10) + ")\n")
229 if total > 0 {
230 b.WriteString("- right now: " + pct(cs.yesStake, total) + " YES\n")
231 }
232 b.WriteString("- pays: not set until the claim is answered — it is " +
233 "this claim's total stake against a typical claim in this court, " +
234 "so staking now is what sets it\n")
235 }
236 h := heightNow()
237 if yAvg, ym := cs.yes.Average(h, periodBlocks); ym {
238 if tAvg, tm := cs.oi.Average(h, periodBlocks); tm && tAvg > 0 {
239 b.WriteString("- trailing week: " + pct(yAvg, tAvg) + " YES\n")
240 }
241 }
242 yc := convToCC(cs.yesConvHi, cs.yesConvLo)
243 nc := convToCC(cs.noConvHi, cs.noConvLo)
244 if yc+nc > 0 {
245 b.WriteString("- lifetime, weighted by how long each stake was held: " + pct(yc, yc+nc) + " YES\n")
246 }
247 b.WriteString("\n## Status\n\n" + claimStatus(cs) + "\n")
248 if cs.frozenAt != 0 {
249 b.WriteString("\n## Resolution\n\n")
250 b.WriteString("- answer: " + sideName(int(cs.answer)) + " (bond " +
251 strconv.FormatInt(cs.answerBond0, 10) + " " + qualifiedSymbol(c) + ")\n")
252 b.WriteString("- the answerer has been challenged and upheld " +
253 strconv.FormatInt(int64(AnswerRecord(slug, cs.answerer)), 10) +
254 " time(s) in this court — answers nobody contested do not count\n")
255 if PriorityGateActive(slug) {
256 b.WriteString("- answer priority is live here — a record of " +
257 strconv.FormatInt(int64(priorityNetRecord), 10) +
258 "+ earns a 24h head start on new claims\n")
259 }
260 if cs.failedRounds > 0 {
261 b.WriteString("- failed dispute rounds: " + strconv.FormatInt(cs.failedRounds, 10) +
262 " of " + strconv.FormatInt(int64(maxFailedRounds), 10) +
263 " — the next dispute bond doubles\n")
264 }
265 if cs.disputeOpen {
266 b.WriteString("- dispute round " + strconv.FormatInt(cs.round, 10) +
267 " vote open — no running total is shown here until it closes\n")
268 } else if cs.verdictAt == 0 {
269 b.WriteString("- disputing this answer costs a bond of " +
270 strconv.FormatInt(cs.disputeBond0(), 10) + " " + qualifiedSymbol(c) +
271 ", doubling for each round that fails to reach a quorum\n")
272 b.WriteString(" - win and the answerer's bond is burned and you are " +
273 "compensated, up to twice your own bond; lose and yours burns " +
274 "instead. Nothing passes between the two of you — a forfeit is " +
275 "burned, a compensation is newly issued\n")
276 b.WriteString(" - if the round draws no quorum, half your bond burns " +
277 "and half returns; three such rounds close the claim undecided, " +
278 "with every stake out at 1× and the deposit and fee refunded\n")
279 b.WriteString(" - **your stake is not at risk either way** — only the " +
280 "bond is\n")
281 }
282 if cs.verdictAt != 0 && !cs.provClose {
283 b.WriteString("- verdict: " + sideName(int(cs.provisional)) + " — " + cs.route + "\n")
284 }
285 if cs.tierRef <= 0 {
286 b.WriteString("- pays " + tierText(tierParBps) +
287 " — this claim was answered before ratings were recorded, so it " +
288 "pays the standard amount\n")
289 } else {
290 b.WriteString("- pays " + tierText(tierBpsFor(cs)) +
291 " — this claim held " + strconv.FormatInt(cs.xBarFrozen, 10) +
292 " " + qualifiedSymbol(c) + " against a typical " +
293 strconv.FormatInt(cs.tierRef, 10) + " " + qualifiedSymbol(c) + " here\n")
294 b.WriteString("- the rating is the money that showed up, not a vote: " +
295 "a claim of typical size pays 1.00×, and it is capped at 0.25× and 2.00×\n")
296 if cs.spamTotalW > 0 && cs.spamW > 0 {
297 b.WriteString("- and " + tierText(spamNetBps(cs)) +
298 " of that, because " + strconv.FormatInt(cs.spamW, 10) +
299 " of " + strconv.FormatInt(cs.spamTotalW, 10) +
300 " of the weight cast flagged it as spam\n")
301 }
302 }
303 switch {
304 }
305 if cs.rewardsOpened {
306 w, a, ans, carrot := cs.drawWinners, cs.drawAuthor, cs.drawAnswerer, cs.carrotPool
307 b.WriteString("- rewards open — pools (" + qualifiedSymbol(c) +
308 "): accuracy " + strconv.FormatInt(w, 10) +
309 ", author " + strconv.FormatInt(a, 10) + ", answerer " + strconv.FormatInt(ans, 10) +
310 ", participation remaining " + strconv.FormatInt(carrot, 10) + "\n")
311 }
312 }
313 b.WriteString("\n_To see your own stake on this claim, put your address on " +
314 "the end of this page's path:_ `/r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:" + sanitize.InlineText(slug) +
315 "/" + strconv.FormatUint(id, 10) + "/<your address>`\n\n")
316 writeBoardLink(&b, c, cs, id)
317 return b.String()
318}
319func renderPositions(slug string, id uint64, addrStr string) string {
320 if !Exists(slug) {
321 return "## Not found\nNo court by that slug."
322 }
323 c := mustCourt(slug)
324 if id == 0 || id > c.nextID {
325 return "## Not found\nNo claim by that id."
326 }
327 who := address(addrStr)
328 if !who.IsValid() {
329 return "## Not found\nThat is not a valid address."
330 }
331 cs := mustClaim(c, id)
332 b := strings.Builder{}
333 b.WriteString("# Positions on this claim\n\n")
334 b.WriteString(hideBanner(c, cs))
335 b.WriteString("Claim: [" + claimTitleFor(c, cs) + "](/r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:" + slug +
336 "/" + strconv.FormatUint(id, 10) + ") in [" + courtNameFor(c) +
337 "](/r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:" + slug + ")\n\n")
338 b.WriteString("Address: " + sanitize.InlineText(addrStr) + "\n\n")
339 yStake := StakeOf(slug, id, sideYES, who)
340 nStake := StakeOf(slug, id, sideNO, who)
341 yConv := ConvictionOf(slug, id, sideYES, who)
342 nConv := ConvictionOf(slug, id, sideNO, who)
343 b.WriteString("## Stake (what you have in, and always get back)\n\n")
344 b.WriteString("- YES: " + strconv.FormatInt(yStake, 10) +
345 " NO: " + strconv.FormatInt(nStake, 10) + " (" + qualifiedSymbol(c) +
346 " base units)\n")
347 b.WriteString("- conviction so far — YES: " + strconv.FormatInt(yConv, 10) +
348 " NO: " + strconv.FormatInt(nConv, 10) + " (the reward weight)\n")
349 bal := c.coin.BalanceOf(who)
350 locked := lockedOf(c, who)
351 voted := voteLockedOf(c, who)
352 b.WriteString("\n## This address's " + qualifiedSymbol(c) +
353 " in this court (all claims)\n\n")
354 b.WriteString("- held: " + strconv.FormatInt(bal, 10) +
355 " — every unit votes, committed or not\n")
356 b.WriteString("- committed as stake: " + strconv.FormatInt(locked, 10) + "\n")
357 b.WriteString("- committed by voting: " + strconv.FormatInt(voted, 10) +
358 " — until each question resolves\n")
359 b.WriteString("- free to stake: " + strconv.FormatInt(spendable(c, who), 10) + "\n")
360 b.WriteString("- free to bond, deposit or transfer: " +
361 strconv.FormatInt(disposable(c, who), 10) + "\n")
362 roles := ""
363 if who == cs.author {
364 roles += "author "
365 }
366 if cs.frozenAt != 0 && who == cs.answerer {
367 roles += "answerer "
368 }
369 if roles != "" {
370 b.WriteString("- roles: " + roles + "\n")
371 }
372 if rec := AnswerRecord(slug, who); rec > 0 {
373 b.WriteString("- answers challenged and upheld in this court: " + strconv.FormatInt(int64(rec), 10) + "\n")
374 }
375 b.WriteString("\n## What you can do\n\n")
376 switch {
377 case cs.verdictAt == 0 && cs.frozenAt == 0:
378 b.WriteString("- staking is open — stake or unstake freely until an answer posts\n")
379 case cs.verdictAt == 0:
380 b.WriteString("- answered, awaiting the verdict — principal is never withheld; withdraw once it is final\n")
381 default:
382 b.WriteString("- the verdict is final — withdraw your principal on either side (1×)\n")
383 if cs.rewardsOpened {
384 win := int(cs.provisional)
385 if !cs.provClose && ((win == sideYES && yStake+yConv > 0) || (win == sideNO && nStake+nConv > 0)) {
386 b.WriteString("- you backed the winning side — pull your accuracy reward\n")
387 }
388 if who == cs.author && cs.drawAuthor > 0 {
389 b.WriteString("- author reward available — pull it\n")
390 }
391 if who == cs.answerer && cs.drawAnswerer > 0 {
392 b.WriteString("- answerer reward available — pull it\n")
393 }
394 }
395 }
396 return b.String()
397}
398func tierText(bps int64) string {
399 if bps < 0 {
400 bps = 0
401 }
402 frac := (bps % tierParBps) / 100
403 out := strconv.FormatInt(bps/tierParBps, 10) + "."
404 if frac < 10 {
405 out += "0"
406 }
407 return out + strconv.FormatInt(frac, 10) + "×"
408}
409func sideName(side int) string {
410 if side == sideYES {
411 return "YES"
412 }
413 return "NO"
414}
415func ClaimStatus(courtSlug string, claimID uint64) string {
416 return claimStatus(mustClaim(mustCourt(courtSlug), claimID))
417}
418func claimStatus(cs *claimState) string {
419 switch {
420 case cs.closed:
421 return "closed — never answered; every stake exited 1×, the deposit refunded"
422 case cs.spamClosed:
423 return "discarded — more than half the weight cast called this claim spam; " +
424 "every stake withdraws 1×, the answerer's and disputer's bonds return, " +
425 "the filing fee burns"
426 case cs.provClose:
427 return "closed without a decision — three dispute rounds failed quorum; everyone withdraws 1×, deposit and fee refunded"
428 case cs.verdictAt != 0:
429 return "settled " + sideName(int(cs.provisional)) + " — every stake withdraws 1×"
430 case cs.disputeOpen:
431 disputed := int(cs.answer)
432 if cs.provisional >= 0 {
433 disputed = int(cs.provisional)
434 }
435 return "disputed " + sideName(disputed) +
436 " — a sealed vote is deciding; principal is never withheld"
437 case cs.provisional >= 0:
438 return "provisional verdict " + sideName(int(cs.provisional)) +
439 " — reopenable by a new dispute until block " + strconv.FormatInt(cs.escrowUntil, 10) +
440 "; the side it is against may withdraw 1× now"
441 case cs.frozenAt != 0:
442 return "answered " + sideName(int(cs.answer)) +
443 " — staking frozen; disputable until block " +
444 strconv.FormatInt(cs.answerHeight+settleDelay, 10) + ", then it settles undisputed"
445 default:
446 return "open — stake YES or NO; unstake freely until an answer posts"
447 }
448}
449func pct(num, den int64) string {
450 if den <= 0 || num < 0 {
451 return "—"
452 }
453 bps := mulDiv128(num, 10000, den)
454 return strconv.FormatInt(bps/100, 10) + "." + pad2(bps%100) + "%"
455}
456func pad2(v int64) string {
457 if v < 10 {
458 return "0" + strconv.FormatInt(v, 10)
459 }
460 return strconv.FormatInt(v, 10)
461}
462func writeFolderIndex(b *strings.Builder, c *Court, slug string) {
463 if c.mod == nil {
464 return
465 }
466 shown := 0
467 for _, f := range c.mod.folderRows() {
468 if f.parent != 0 || f.retired || f.purged {
469 continue
470 }
471 if shown == 0 {
472 b.WriteString("Filed under: ")
473 } else {
474 b.WriteString(" · ")
475 }
476 b.WriteString("[" + sanitize.InlineText(f.name) + "](/r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:" +
477 slug + "/folder/" + strconv.FormatUint(f.id, 10) + ")")
478 shown++
479 }
480 if shown > 0 {
481 b.WriteString("\n\n")
482 }
483}
484func renderFolderPage(slug string, fid uint64) string {
485 if !Exists(slug) {
486 return "## Not found\nNo court by that slug."
487 }
488 c := mustCourt(slug)
489 if c.mod == nil {
490 return "## Not found\nThis court has no folders."
491 }
492 v := c.mod.folders.Get(beClaimKey(fid))
493 if v == nil {
494 return "## Not found\nNo folder by that id."
495 }
496 f := v.(*folder)
497 base := "/r/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/kourt:" + slug
498 var b strings.Builder
499 if f.purged {
500 b.WriteString("# [purged:" + sanitize.InlineText(f.code) + "]\n\n")
501 b.WriteString("_This heading was purged by the global DAO. Its name and " +
502 "description were erased, not hidden._\n\n")
503 b.WriteString("[← " + courtNameFor(c) + "](" + base + ")\n")
504 return b.String()
505 }
506 b.WriteString("# " + sanitize.InlineText(f.name) + "\n\n")
507 b.WriteString("[← " + courtNameFor(c) + "](" + base + ")")
508 for up, n := f.parent, 0; up != 0 && n <= maxFolders; n++ {
509 pv := c.mod.folders.Get(beClaimKey(up))
510 if pv == nil {
511 break
512 }
513 pf := pv.(*folder)
514 b.WriteString(" · [" + sanitize.InlineText(pf.name) + "](" + base +
515 "/folder/" + strconv.FormatUint(pf.id, 10) + ")")
516 up = pf.parent
517 }
518 b.WriteString("\n\n")
519 if f.retired {
520 b.WriteString("> This heading was retired by a moderator. It is kept so " +
521 "links to it still resolve.\n\n")
522 }
523 if f.bornOf != 0 {
524 b.WriteString("> The court voted this heading into existence: [claim #" +
525 strconv.FormatUint(f.bornOf, 10) + "](" + base + "/" +
526 strconv.FormatUint(f.bornOf, 10) + ") settled YES, and carrying it was " +
527 "a second act anyone could take.\n\n")
528 }
529 if f.desc != "" {
530 b.WriteString(sanitize.Block(f.desc) + "\n")
531 }
532 kids := 0
533 for _, k := range c.mod.folderRows() {
534 if k.parent != f.id || k.retired || k.purged {
535 continue
536 }
537 if kids == 0 {
538 b.WriteString("## Under this heading\n\n")
539 }
540 kids++
541 b.WriteString("- [" + sanitize.InlineText(k.name) + "](" + base + "/folder/" +
542 strconv.FormatUint(k.id, 10) + ")\n")
543 }
544 if kids > 0 {
545 b.WriteString("\n")
546 }
547 b.WriteString("## Claims filed here\n\n")
548 filed := 0
549 for _, id := range f.items {
550 if id == 0 || id > c.nextID {
551 continue
552 }
553 cs := mustClaim(c, id)
554 filed++
555 b.WriteString("- [" + claimTitleFor(c, cs) + "](" + base + "/" +
556 strconv.FormatUint(id, 10) + ") — " + claimStatus(cs) + "\n")
557 }
558 if filed == 0 {
559 b.WriteString("_Nothing filed here yet._\n")
560 }
561 b.WriteString("\n_A folder curates and can never bury: every claim above is " +
562 "also on the court's own newest-first list, and nothing is reachable only " +
563 "from here._\n\n")
564 b.WriteString(helpLink + "\n")
565 return b.String()
566}