render.gno
20.53 Kb · 582 lines
1package kourtv3
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/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + 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/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:<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/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + 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/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:<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/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + 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/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + 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/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + 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 nextCoin, ok := c.crv.Cost(c.minted, coinUnit)
181 if ok {
182 b.WriteString("- price: " + strconv.FormatInt(nextCoin, 10) +
183 " ugnot per coin — the next coin's price, and it only rises\n")
184 } else {
185 b.WriteString("- price: the curve is at its cap; no more coin can be minted\n")
186 }
187 if c.oneWay {
188 b.WriteString("- one-way: every payment burns in full; nothing is held and " +
189 "nothing returns GNOT\n")
190 } else {
191 perCoin, _ := redeemQuote(c, coinUnit)
192 b.WriteString("- held for the coin: " + ugnotAmt(c.reserve) + " GNOT · " +
193 "one coin returns " + strconv.FormatInt(perCoin, 10) +
194 " ugnot right now (what is held × coin ÷ supply, rounded down; " +
195 "below the next coin's price unless coin has been destroyed by bonds " +
196 "since the last offering) · " + pct(burnBps, bpsDenom) +
197 " of each payment burns\n")
198 }
199 b.WriteString("- in circulation: " + strconv.FormatInt(c.coin.TotalSupply(), 10) +
200 " · paid out as rewards so far: " + strconv.FormatInt(c.emittedTotal, 10) + "\n")
201 b.WriteString("- reward reservoir: " + strconv.FormatInt(c.reservoirR(), 10) +
202 " waiting to be earned · " + strconv.FormatInt(c.seniorOwed, 10) +
203 " already owed to earlier earners\n\n")
204 b.WriteString(helpLink + "\n\n")
205}
206func renderClaim(slug string, id uint64) string {
207 if !Exists(slug) {
208 return "## Not found\nNo court by that slug."
209 }
210 c := mustCourt(slug)
211 if id == 0 || id > c.nextID {
212 return "## Not found\nNo claim by that id."
213 }
214 cs := mustClaim(c, id)
215 b := strings.Builder{}
216 b.WriteString("# " + claimTitleFor(c, cs) + "\n\n")
217 if body := claimBodyQuoted(c, cs); body != "" {
218 b.WriteString(body)
219 }
220 writeClaimMedia(&b, c, cs)
221 b.WriteString(hideBanner(c, cs))
222 writeRelations(&b, c, id)
223 writeAppeal(&b, c, cs)
224 if cs.seeded {
225 b.WriteString("_Seeded by a moderator to start this court; the author earns nothing from it._\n\n")
226 }
227 b.WriteString("## Signal\n\n")
228 if cs.frozenAt != 0 {
229 fTotal := cs.yesStakeAtFreeze + cs.noStakeAtFreeze
230 b.WriteString("- staked when answered: " + strconv.FormatInt(fTotal, 10) +
231 " " + qualifiedSymbol(c) + " (YES " + strconv.FormatInt(cs.yesStakeAtFreeze, 10) +
232 " / NO " + strconv.FormatInt(cs.noStakeAtFreeze, 10) + ")\n")
233 if fTotal > 0 {
234 b.WriteString("- lean when answered: " + pct(cs.yesStakeAtFreeze, fTotal) + " YES\n")
235 }
236 if live := cs.yesStake + cs.noStake; live > 0 {
237 b.WriteString("- still unwithdrawn: " + strconv.FormatInt(live, 10) +
238 " " + qualifiedSymbol(c) + "\n")
239 }
240 } else {
241 total := cs.yesStake + cs.noStake
242 b.WriteString("- staked now: " + strconv.FormatInt(total, 10) +
243 " " + qualifiedSymbol(c) + " (YES " + strconv.FormatInt(cs.yesStake, 10) +
244 " / NO " + strconv.FormatInt(cs.noStake, 10) + ")\n")
245 if total > 0 {
246 b.WriteString("- right now: " + pct(cs.yesStake, total) + " YES\n")
247 }
248 b.WriteString("- pays: not set until the claim is answered — it is " +
249 "this claim's total stake against a typical claim in this court, " +
250 "so staking now is what sets it\n")
251 }
252 h := heightNow()
253 if yAvg, ym := cs.yes.Average(h, periodBlocks); ym {
254 if tAvg, tm := cs.oi.Average(h, periodBlocks); tm && tAvg > 0 {
255 b.WriteString("- trailing week: " + pct(yAvg, tAvg) + " YES\n")
256 }
257 }
258 yc := convToCC(cs.yesConvHi, cs.yesConvLo)
259 nc := convToCC(cs.noConvHi, cs.noConvLo)
260 if yc+nc > 0 {
261 b.WriteString("- lifetime, weighted by how long each stake was held: " + pct(yc, yc+nc) + " YES\n")
262 }
263 b.WriteString("\n## Status\n\n" + claimStatus(cs) + "\n")
264 if cs.frozenAt != 0 {
265 b.WriteString("\n## Resolution\n\n")
266 b.WriteString("- answer: " + sideName(int(cs.answer)) + " (bond " +
267 strconv.FormatInt(cs.answerBond0, 10) + " " + qualifiedSymbol(c) + ")\n")
268 b.WriteString("- the answerer has been challenged and upheld " +
269 strconv.FormatInt(int64(AnswerRecord(slug, cs.answerer)), 10) +
270 " time(s) in this court — answers nobody contested do not count\n")
271 if PriorityGateActive(slug) {
272 b.WriteString("- answer priority is live here — a record of " +
273 strconv.FormatInt(int64(priorityNetRecord), 10) +
274 "+ earns a 24h head start on new claims\n")
275 }
276 if cs.failedRounds > 0 {
277 b.WriteString("- failed dispute rounds: " + strconv.FormatInt(cs.failedRounds, 10) +
278 " of " + strconv.FormatInt(int64(maxFailedRounds), 10) +
279 " — the next dispute bond doubles\n")
280 }
281 if cs.disputeOpen {
282 b.WriteString("- dispute round " + strconv.FormatInt(cs.round, 10) +
283 " vote open — no running total is shown here until it closes\n")
284 } else if cs.verdictAt == 0 {
285 b.WriteString("- disputing this answer costs a bond of " +
286 strconv.FormatInt(cs.disputeBond0(), 10) + " " + qualifiedSymbol(c) +
287 ", doubling for each round that fails to reach a quorum\n")
288 b.WriteString(" - win and the answerer's bond is burned and you are " +
289 "compensated, up to twice your own bond; lose and yours burns " +
290 "instead. Nothing passes between the two of you — a forfeit is " +
291 "burned, a compensation is newly issued\n")
292 b.WriteString(" - if the round draws no quorum, half your bond burns " +
293 "and half returns; three such rounds close the claim undecided, " +
294 "with every stake out at 1× and the deposit and fee refunded\n")
295 b.WriteString(" - **your stake is not at risk either way** — only the " +
296 "bond is\n")
297 }
298 if cs.verdictAt != 0 && !cs.provClose {
299 b.WriteString("- verdict: " + sideName(int(cs.provisional)) + " — " + cs.route + "\n")
300 }
301 if cs.tierRef <= 0 {
302 b.WriteString("- pays " + tierText(tierParBps) +
303 " — this claim was answered before ratings were recorded, so it " +
304 "pays the standard amount\n")
305 } else {
306 b.WriteString("- pays " + tierText(tierBpsFor(cs)) +
307 " — this claim held " + strconv.FormatInt(cs.xBarFrozen, 10) +
308 " " + qualifiedSymbol(c) + " against a typical " +
309 strconv.FormatInt(cs.tierRef, 10) + " " + qualifiedSymbol(c) + " here\n")
310 b.WriteString("- the rating is the money that showed up, not a vote: " +
311 "a claim of typical size pays 1.00×, and it is capped at 0.25× and 2.00×\n")
312 if cs.spamTotalW > 0 && cs.spamW > 0 {
313 b.WriteString("- and " + tierText(spamNetBps(cs)) +
314 " of that, because " + strconv.FormatInt(cs.spamW, 10) +
315 " of " + strconv.FormatInt(cs.spamTotalW, 10) +
316 " of the weight cast flagged it as spam\n")
317 }
318 }
319 switch {
320 }
321 if cs.rewardsOpened {
322 w, a, ans, carrot := cs.drawWinners, cs.drawAuthor, cs.drawAnswerer, cs.carrotPool
323 b.WriteString("- rewards open — pools (" + qualifiedSymbol(c) +
324 "): accuracy " + strconv.FormatInt(w, 10) +
325 ", author " + strconv.FormatInt(a, 10) + ", answerer " + strconv.FormatInt(ans, 10) +
326 ", participation remaining " + strconv.FormatInt(carrot, 10) + "\n")
327 }
328 }
329 b.WriteString("\n_To see your own stake on this claim, put your address on " +
330 "the end of this page's path:_ `/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + sanitize.InlineText(slug) +
331 "/" + strconv.FormatUint(id, 10) + "/<your address>`\n\n")
332 writeBoardLink(&b, c, cs, id)
333 return b.String()
334}
335func renderPositions(slug string, id uint64, addrStr string) string {
336 if !Exists(slug) {
337 return "## Not found\nNo court by that slug."
338 }
339 c := mustCourt(slug)
340 if id == 0 || id > c.nextID {
341 return "## Not found\nNo claim by that id."
342 }
343 who := address(addrStr)
344 if !who.IsValid() {
345 return "## Not found\nThat is not a valid address."
346 }
347 cs := mustClaim(c, id)
348 b := strings.Builder{}
349 b.WriteString("# Positions on this claim\n\n")
350 b.WriteString(hideBanner(c, cs))
351 b.WriteString("Claim: [" + claimTitleFor(c, cs) + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug +
352 "/" + strconv.FormatUint(id, 10) + ") in [" + courtNameFor(c) +
353 "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug + ")\n\n")
354 b.WriteString("Address: " + sanitize.InlineText(addrStr) + "\n\n")
355 yStake := StakeOf(slug, id, sideYES, who)
356 nStake := StakeOf(slug, id, sideNO, who)
357 yConv := ConvictionOf(slug, id, sideYES, who)
358 nConv := ConvictionOf(slug, id, sideNO, who)
359 b.WriteString("## Stake (what you have in, and always get back)\n\n")
360 b.WriteString("- YES: " + strconv.FormatInt(yStake, 10) +
361 " NO: " + strconv.FormatInt(nStake, 10) + " (" + qualifiedSymbol(c) +
362 " base units)\n")
363 b.WriteString("- conviction so far — YES: " + strconv.FormatInt(yConv, 10) +
364 " NO: " + strconv.FormatInt(nConv, 10) + " (the reward weight)\n")
365 bal := c.coin.BalanceOf(who)
366 locked := lockedOf(c, who)
367 voted := voteLockedOf(c, who)
368 b.WriteString("\n## This address's " + qualifiedSymbol(c) +
369 " in this court (all claims)\n\n")
370 b.WriteString("- held: " + strconv.FormatInt(bal, 10) +
371 " — every unit votes, committed or not\n")
372 b.WriteString("- committed as stake: " + strconv.FormatInt(locked, 10) + "\n")
373 b.WriteString("- committed by voting: " + strconv.FormatInt(voted, 10) +
374 " — until each question resolves\n")
375 b.WriteString("- free to stake: " + strconv.FormatInt(spendable(c, who), 10) + "\n")
376 b.WriteString("- free to bond, deposit or transfer: " +
377 strconv.FormatInt(disposable(c, who), 10) + "\n")
378 roles := ""
379 if who == cs.author {
380 roles += "author "
381 }
382 if cs.frozenAt != 0 && who == cs.answerer {
383 roles += "answerer "
384 }
385 if roles != "" {
386 b.WriteString("- roles: " + roles + "\n")
387 }
388 if rec := AnswerRecord(slug, who); rec > 0 {
389 b.WriteString("- answers challenged and upheld in this court: " + strconv.FormatInt(int64(rec), 10) + "\n")
390 }
391 b.WriteString("\n## What you can do\n\n")
392 switch {
393 case cs.verdictAt == 0 && cs.frozenAt == 0:
394 b.WriteString("- staking is open — stake or unstake freely until an answer posts\n")
395 case cs.verdictAt == 0:
396 b.WriteString("- answered, awaiting the verdict — principal is never withheld; withdraw once it is final\n")
397 default:
398 b.WriteString("- the verdict is final — withdraw your principal on either side (1×)\n")
399 if cs.rewardsOpened {
400 win := int(cs.provisional)
401 if !cs.provClose && ((win == sideYES && yStake+yConv > 0) || (win == sideNO && nStake+nConv > 0)) {
402 b.WriteString("- you backed the winning side — pull your accuracy reward\n")
403 }
404 if who == cs.author && cs.drawAuthor > 0 {
405 b.WriteString("- author reward available — pull it\n")
406 }
407 if who == cs.answerer && cs.drawAnswerer > 0 {
408 b.WriteString("- answerer reward available — pull it\n")
409 }
410 }
411 }
412 return b.String()
413}
414func tierText(bps int64) string {
415 if bps < 0 {
416 bps = 0
417 }
418 frac := (bps % tierParBps) / 100
419 out := strconv.FormatInt(bps/tierParBps, 10) + "."
420 if frac < 10 {
421 out += "0"
422 }
423 return out + strconv.FormatInt(frac, 10) + "×"
424}
425func sideName(side int) string {
426 if side == sideYES {
427 return "YES"
428 }
429 return "NO"
430}
431func ClaimStatus(courtSlug string, claimID uint64) string {
432 return claimStatus(mustClaim(mustCourt(courtSlug), claimID))
433}
434func claimStatus(cs *claimState) string {
435 switch {
436 case cs.closed:
437 return "closed — never answered; every stake exited 1×, the deposit refunded"
438 case cs.spamClosed:
439 return "discarded — more than half the weight cast called this claim spam; " +
440 "every stake withdraws 1×, the answerer's and disputer's bonds return, " +
441 "the filing fee burns"
442 case cs.provClose:
443 return "closed without a decision — three dispute rounds failed quorum; everyone withdraws 1×, deposit and fee refunded"
444 case cs.verdictAt != 0:
445 return "settled " + sideName(int(cs.provisional)) + " — every stake withdraws 1×"
446 case cs.disputeOpen:
447 disputed := int(cs.answer)
448 if cs.provisional >= 0 {
449 disputed = int(cs.provisional)
450 }
451 return "disputed " + sideName(disputed) +
452 " — a sealed vote is deciding; principal is never withheld"
453 case cs.provisional >= 0:
454 return "provisional verdict " + sideName(int(cs.provisional)) +
455 " — reopenable by a new dispute until block " + strconv.FormatInt(cs.escrowUntil, 10) +
456 "; the side it is against may withdraw 1× now"
457 case cs.frozenAt != 0:
458 return "answered " + sideName(int(cs.answer)) +
459 " — staking frozen; disputable until block " +
460 strconv.FormatInt(cs.answerHeight+settleDelay, 10) + ", then it settles undisputed"
461 default:
462 return "open — stake YES or NO; unstake freely until an answer posts"
463 }
464}
465func pct(num, den int64) string {
466 if den <= 0 || num < 0 {
467 return "—"
468 }
469 bps := mulDiv128(num, 10000, den)
470 return strconv.FormatInt(bps/100, 10) + "." + pad2(bps%100) + "%"
471}
472func pad2(v int64) string {
473 if v < 10 {
474 return "0" + strconv.FormatInt(v, 10)
475 }
476 return strconv.FormatInt(v, 10)
477}
478func writeFolderIndex(b *strings.Builder, c *Court, slug string) {
479 if c.mod == nil {
480 return
481 }
482 shown := 0
483 for _, f := range c.mod.folderRows() {
484 if f.parent != 0 || f.retired || f.purged {
485 continue
486 }
487 if shown == 0 {
488 b.WriteString("Filed under: ")
489 } else {
490 b.WriteString(" · ")
491 }
492 b.WriteString("[" + sanitize.InlineText(f.name) + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" +
493 slug + "/folder/" + strconv.FormatUint(f.id, 10) + ")")
494 shown++
495 }
496 if shown > 0 {
497 b.WriteString("\n\n")
498 }
499}
500func renderFolderPage(slug string, fid uint64) string {
501 if !Exists(slug) {
502 return "## Not found\nNo court by that slug."
503 }
504 c := mustCourt(slug)
505 if c.mod == nil {
506 return "## Not found\nThis court has no folders."
507 }
508 v := c.mod.folders.Get(beClaimKey(fid))
509 if v == nil {
510 return "## Not found\nNo folder by that id."
511 }
512 f := v.(*folder)
513 base := "/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/kourtv3:" + slug
514 var b strings.Builder
515 if f.purged {
516 b.WriteString("# [purged:" + sanitize.InlineText(f.code) + "]\n\n")
517 b.WriteString("_This heading was purged by the global DAO. Its name and " +
518 "description were erased, not hidden._\n\n")
519 b.WriteString("[← " + courtNameFor(c) + "](" + base + ")\n")
520 return b.String()
521 }
522 b.WriteString("# " + sanitize.InlineText(f.name) + "\n\n")
523 b.WriteString("[← " + courtNameFor(c) + "](" + base + ")")
524 for up, n := f.parent, 0; up != 0 && n <= maxFolders; n++ {
525 pv := c.mod.folders.Get(beClaimKey(up))
526 if pv == nil {
527 break
528 }
529 pf := pv.(*folder)
530 b.WriteString(" · [" + sanitize.InlineText(pf.name) + "](" + base +
531 "/folder/" + strconv.FormatUint(pf.id, 10) + ")")
532 up = pf.parent
533 }
534 b.WriteString("\n\n")
535 if f.retired {
536 b.WriteString("> This heading was retired by a moderator. It is kept so " +
537 "links to it still resolve.\n\n")
538 }
539 if f.bornOf != 0 {
540 b.WriteString("> The court voted this heading into existence: [claim #" +
541 strconv.FormatUint(f.bornOf, 10) + "](" + base + "/" +
542 strconv.FormatUint(f.bornOf, 10) + ") settled YES, and carrying it was " +
543 "a second act anyone could take.\n\n")
544 }
545 if f.desc != "" {
546 b.WriteString(sanitize.Block(f.desc) + "\n")
547 }
548 kids := 0
549 for _, k := range c.mod.folderRows() {
550 if k.parent != f.id || k.retired || k.purged {
551 continue
552 }
553 if kids == 0 {
554 b.WriteString("## Under this heading\n\n")
555 }
556 kids++
557 b.WriteString("- [" + sanitize.InlineText(k.name) + "](" + base + "/folder/" +
558 strconv.FormatUint(k.id, 10) + ")\n")
559 }
560 if kids > 0 {
561 b.WriteString("\n")
562 }
563 b.WriteString("## Claims filed here\n\n")
564 filed := 0
565 for _, id := range f.items {
566 if id == 0 || id > c.nextID {
567 continue
568 }
569 cs := mustClaim(c, id)
570 filed++
571 b.WriteString("- [" + claimTitleFor(c, cs) + "](" + base + "/" +
572 strconv.FormatUint(id, 10) + ") — " + claimStatus(cs) + "\n")
573 }
574 if filed == 0 {
575 b.WriteString("_Nothing filed here yet._\n")
576 }
577 b.WriteString("\n_A folder curates and can never bury: every claim above is " +
578 "also on the court's own newest-first list, and nothing is reachable only " +
579 "from here._\n\n")
580 b.WriteString(helpLink + "\n")
581 return b.String()
582}