Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

render.gno

42.84 Kb · 1133 lines
   1package bolo
   2
   3import (
   4	"math"
   5	"strconv"
   6	"strings"
   7	"time"
   8
   9	"gno.land/p/moul/txlink/v0"
  10	"gno.land/p/nt/markdown/sanitize/v0"
  11)
  12
  13// Render (API §5): gnoweb's pages, each laid out for who reads it (UI spec §4.13). The
  14// path is attacker-controlled: it is parsed into typed values, and anything that fails
  15// gets one fixed page that echoes nothing from the request. User text is escaped by
  16// who wrote it: strangers and moderators through inline, the organizer through
  17// Blockquote; addresses go into code spans, where gnoweb makes no link of them. Render
  18// writes nothing and reads balances only through views.
  19
  20const (
  21	webPath        = "/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0" // this realm on gnoweb
  22	maxPathLen     = 256                 // longer than any valid path
  23	listPage       = 20                  // entries a list page shows
  24	updatesPage    = 5                   // updates a page shows: each can cost 0.18 billion gas to render
  25	addressSection = 10                  // entries each address-page section shows
  26	barWide        = 1000                // a progress bar's width across a page, scaled down to fit
  27	barCard        = 400                 // and in a card, half a row
  28	notFound       = "# Not found\n\nThere is nothing here. [Bolo](" + webPath + ")\n"
  29)
  30
  31// Render returns the markdown page for path (API §5).
  32func Render(path string) string {
  33	if len(path) > maxPathLen {
  34		return notFound
  35	}
  36	route, rawQuery, _ := strings.Cut(path, "?")
  37	q, ok := parseQuery(rawQuery)
  38	if !ok {
  39		return notFound
  40	}
  41	if out, ok := route2page(route, q); ok {
  42		return out
  43	}
  44	return notFound
  45}
  46
  47// route2page renders route, or reports false when route names nothing or a value in
  48// q doesn't parse. Every prefix of a route renders a page, as gnoweb's header links
  49// each one: "c" the home page, "c/{id}/r" the gifts, "u" the form to find an address.
  50func route2page(route string, q map[string]string) (string, bool) {
  51	switch route {
  52	case "", "c":
  53		return renderHome(q)
  54	case "u":
  55		return renderLookup(q), true
  56	case "about":
  57		return renderAbout(), true
  58	case "params":
  59		return renderParams(), true
  60	}
  61	segs := strings.Split(route, "/")
  62	switch {
  63	case segs[0] == "c" && len(segs) >= 2 && len(segs) <= 4:
  64		c, ok := lookup(segs[1])
  65		if !ok {
  66			return "", false
  67		}
  68		switch {
  69		case len(segs) == 2:
  70			return renderCampaign(c), true
  71		case len(segs) == 3 && (segs[2] == "donations" || segs[2] == "r"):
  72			return pageOf(q, func(page int) string { return renderDonations(c, page) })
  73		case len(segs) == 3 && segs[2] == "give":
  74			return renderGive(c, q), true
  75		case len(segs) == 3 && segs[2] == "ledger":
  76			return pageOf(q, func(page int) string { return renderLedger(c, page) })
  77		case len(segs) == 3 && segs[2] == "updates":
  78			return pageOf(q, func(page int) string { return renderUpdates(c, page) })
  79		case len(segs) == 4 && segs[2] == "r":
  80			seq, ok := parseID(segs[3])
  81			if !ok || c.receipts.Get(seq.Binary()) == nil {
  82				return "", false
  83			}
  84			return renderReceipt(c, int64(seq)), true
  85		}
  86	case segs[0] == "u" && (len(segs) == 2 || len(segs) == 3):
  87		addr, ok := parseAddress(segs[1])
  88		if !ok {
  89			return "", false
  90		}
  91		if len(segs) == 2 {
  92			return renderAddress(addr), true
  93		}
  94		list, heading := addressList(segs[2])
  95		if list == nil {
  96			return "", false
  97		}
  98		after, ok := q["after"]
  99		if ok {
 100			if _, canonical := parseID(after); !canonical {
 101				return "", false
 102			}
 103		}
 104		return renderAddressList(addr, segs[2], heading, list, after), true
 105	}
 106	return "", false
 107}
 108
 109// parseQuery splits a query into its key=value pairs: an empty pair is skipped, a key
 110// given twice fails. Values are taken as they are: the routes parse them.
 111func parseQuery(raw string) (map[string]string, bool) {
 112	q := map[string]string{}
 113	if raw == "" {
 114		return q, true
 115	}
 116	for _, pair := range strings.Split(raw, "&") {
 117		if pair == "" {
 118			continue
 119		}
 120		k, v, _ := strings.Cut(pair, "=")
 121		if _, seen := q[k]; seen {
 122			return nil, false
 123		}
 124		q[k] = v
 125	}
 126	return q, true
 127}
 128
 129// pageOf calls render with the page q asks for, 1 when it asks none, or fails when
 130// its page isn't a canonical number of at least 1.
 131func pageOf(q map[string]string, render func(page int) string) (string, bool) {
 132	page := 1
 133	if v, ok := q["page"]; ok {
 134		n, ok := parsePage(v)
 135		if !ok {
 136			return "", false
 137		}
 138		page = n
 139	}
 140	return render(page), true
 141}
 142
 143// parsePage parses a page number: canonical decimal — no sign, no leading zero — from
 144// 1, at most nine digits.
 145func parsePage(s string) (int, bool) {
 146	if s == "" || len(s) > 9 || s[0] == '0' {
 147		return 0, false
 148	}
 149	n := 0
 150	for i := 0; i < len(s); i++ {
 151		if s[i] < '0' || s[i] > '9' {
 152			return 0, false
 153		}
 154		n = n*10 + int(s[i]-'0')
 155	}
 156	return n, true
 157}
 158
 159// parseAddress checks an address from a path as C12 checks an argument: exactly 40
 160// characters, a lowercase "g" first, then IsValid.
 161func parseAddress(s string) (address, bool) {
 162	if len(s) != 40 || s[0] != 'g' || !address(s).IsValid() {
 163		return "", false
 164	}
 165	return address(s), true
 166}
 167
 168// renderHome is the home page: the guarantees in one line, how it works, the links to
 169// start a campaign and to find an address, the newest active campaigns in a category
 170// or all, 20 at a time by cursor, and the categories.
 171func renderHome(q map[string]string) (string, bool) {
 172	after, ok := q["after"]
 173	if ok {
 174		if _, canonical := parseID(after); !canonical {
 175			return "", false
 176		}
 177	}
 178	cat, set := q["cat"]
 179	if set && !categories.Has(cat) {
 180		return "", false
 181	}
 182	var b strings.Builder
 183	b.WriteString("# Bolo\n\nGive together. See where it goes.\n\n")
 184	b.WriteString("**0% platform fee · every gift checkable · nothing in Bolo can freeze your gift** — [what Bolo guarantees, and what it doesn't](" + webPath + ":about)\n\n")
 185	b.WriteString("How it works: give from your wallet → get a receipt → check where the money went.\n\n")
 186	writeStart(&b)
 187	if cat == "" {
 188		b.WriteString("## Newest campaigns\n\n")
 189	} else {
 190		b.WriteString("## Newest campaigns in " + cat + "\n\n")
 191	}
 192	views, next := ListCampaigns(after, listPage, cat)
 193	writeCards(&b, views)
 194	switch {
 195	case len(views) > 0:
 196	case after == "" && next == "":
 197		b.WriteString("No campaigns here yet.\n\n")
 198	default:
 199		b.WriteString("None on this page.\n\n")
 200	}
 201	if next != "" {
 202		more := webPath + ":?after=" + next
 203		if cat != "" {
 204			more += "&cat=" + cat
 205		}
 206		b.WriteString("[More](" + more + ")\n\n")
 207	}
 208	b.WriteString("## Categories\n\n")
 209	for i, code := range categoryOrder {
 210		if i > 0 {
 211			b.WriteString(" · ")
 212		}
 213		b.WriteString("[" + code + "](" + webPath + ":?cat=" + code + ")")
 214	}
 215	b.WriteString("\n")
 216	return b.String(), true
 217}
 218
 219// writeStart writes the link to start a campaign — CreateCampaign's form, the creation
 220// deposit attached — while this version takes new campaigns, or why it doesn't; then
 221// the link to find an address.
 222func writeStart(b *strings.Builder) {
 223	switch {
 224	case sunset:
 225		b.WriteString("This version of Bolo takes no new campaigns: [see where they start](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo)")
 226	case paused:
 227		b.WriteString("Bolo is paused: new campaigns and gifts wait until it resumes")
 228	default:
 229		link := txlink.Realm(pkgPath).NewLink("CreateCampaign").SetSend(strconv.FormatInt(params.CreationDeposit, 10) + "ugnot").URL()
 230		b.WriteString("[Start a campaign](" + link + ") — a " + gnot(params.CreationDeposit) + " deposit, returned when it completes")
 231	}
 232	b.WriteString(" · [Find an address](" + webPath + ":u)\n\n")
 233}
 234
 235// writeCards writes campaigns as cards, two to a row in gnoweb's columns (UI spec
 236// §4.13): the title as a link, the progress bar, raised of the goal and donors, then
 237// the mode, the category, the time left or how it ended, and a bond or a moderator's
 238// notice when there is one. The one user text, the title, goes through inline, whose
 239// InlineText escapes "<": nothing in it can open, separate or close a block. A
 240// separator is written self-closing, the spelling every gnoweb reads as one.
 241func writeCards(b *strings.Builder, views []CampaignView) {
 242	for i, v := range views {
 243		if i%2 == 0 {
 244			b.WriteString("<gno-columns>\n")
 245		} else {
 246			b.WriteString("<gno-columns-sep />\n")
 247		}
 248		b.WriteString("### [" + inline(v.Title) + "](" + webPath + ":c/" + v.ID + ")\n\n")
 249		if bar := progressBar(v.NetRaised, v.Goal, barCard); bar != "" {
 250			b.WriteString(bar + "\n\n")
 251		}
 252		b.WriteString("**" + gnot(v.NetRaised) + "**")
 253		if v.Goal > 0 {
 254			b.WriteString(" of " + gnot(v.Goal))
 255		} else {
 256			b.WriteString(" raised")
 257		}
 258		b.WriteString(" · " + count(v.DonorCount, "donor", "donors") + "  \n")
 259		b.WriteString(modeWord(v.Mode) + " · " + v.Category + " · " + timeWord(v))
 260		if v.Bond > 0 {
 261			if v.BondSlashed {
 262				b.WriteString(" · bond slashed")
 263			} else {
 264				b.WriteString(" · bond " + gnot(v.Bond))
 265			}
 266		}
 267		if v.Notice != "" {
 268			b.WriteString(" · **moderator's notice**")
 269		}
 270		b.WriteString("\n")
 271		if i%2 == 1 || i == len(views)-1 {
 272			if i%2 == 0 { // a row's last card alone: an empty column keeps it half wide
 273				b.WriteString("<gno-columns-sep />\n")
 274			}
 275			b.WriteString("</gno-columns>\n\n")
 276		}
 277	}
 278}
 279
 280// timeWord says where a campaign stands in time, on a card: the time left, no
 281// deadline, or how it ended.
 282func timeWord(v CampaignView) string {
 283	switch {
 284	case v.Status == statusSettled:
 285		return "completed"
 286	case v.Status != statusOpen:
 287		return "closed to new donations"
 288	case v.Deadline != 0:
 289		return left(v.Deadline)
 290	}
 291	return "no deadline"
 292}
 293
 294// renderCampaign is a campaign's page (API §5, UI spec §4.13), Bolo's own blocks
 295// before the organizer's words: the notice, the photo, the title and byline, the
 296// figures, the mode, the state banner, the donate links, where the money is, the
 297// verification block; then the story as a quote; then the recent gifts, the latest
 298// update, and last the actions of the organizer, the beneficiary and anyone.
 299func renderCampaign(c *campaign) string {
 300	v := campaignView(c)
 301	space := room(v)
 302	anyone, reason := CanClose(v.ID, "")
 303	var b strings.Builder
 304	if v.Notice != "" {
 305		b.WriteString("> [!WARNING] Notice from Bolo's moderator\n> " + lineStart(v.Notice) + "\n\n")
 306	}
 307	if v.ImageCID != "" && !v.ImageHidden {
 308		b.WriteString("![" + inline(v.ImageAlt) + "](ipfs://" + v.ImageCID + ")\n\n")
 309	}
 310	b.WriteString("# " + lineStart(v.Title) + "\n\n")
 311	b.WriteString(v.Category + " · by " + addrLink(v.Organizer) + " · for " + addrLink(v.Beneficiary))
 312	if !v.Listed {
 313		b.WriteString(" · **hidden from listings**")
 314	}
 315	b.WriteString("\n\n")
 316	writeFigures(&b, v)
 317	writeMode(&b, v)
 318	writeBanner(&b, v, space, anyone && reason == reasonDormant)
 319	writeGive(&b, v, space)
 320	writeMoney(&b, v)
 321	writeVerify(&b, v)
 322	if v.Description != "" {
 323		b.WriteString("---\n\n## In the organizer's words\n")
 324		b.WriteString(sanitize.Blockquote(v.Description))
 325		b.WriteString("---\n\n")
 326	}
 327	b.WriteString("## Recent gifts\n\n")
 328	recent := ListRecent(v.ID)
 329	for _, r := range recent {
 330		writeGift(&b, r)
 331	}
 332	if len(recent) == 0 {
 333		b.WriteString("No gifts yet.\n")
 334	}
 335	b.WriteString("\n[All gifts](" + webPath + ":c/" + v.ID + "/donations)")
 336	if v.Mode == modeEscrow {
 337		b.WriteString(" · [Money out](" + webPath + ":c/" + v.ID + "/ledger)")
 338	}
 339	b.WriteString("\n\n")
 340	if v.UpdateCount > 0 {
 341		b.WriteString("## Latest update\n\n")
 342		u := ListUpdates(v.ID, 1, 1)[0]
 343		b.WriteString("**" + when(u.At) + "**\n")
 344		b.WriteString(sanitize.Blockquote(u.Body))
 345		b.WriteString("[All updates](" + webPath + ":c/" + v.ID + "/updates)\n\n")
 346	}
 347	writeManage(&b, v, anyone)
 348	return b.String()
 349}
 350
 351// writeFigures writes the progress bar, then three figures, a short line each, which
 352// stay short on a phone: raised, of the goal; donors and gifts; the time left, or how
 353// it ended.
 354func writeFigures(b *strings.Builder, v CampaignView) {
 355	if bar := progressBar(v.NetRaised, v.Goal, barWide); bar != "" {
 356		b.WriteString(bar + "\n\n")
 357	}
 358	b.WriteString("**" + gnot(v.NetRaised) + "** raised")
 359	if v.Goal > 0 {
 360		b.WriteString(" · " + percent(v.NetRaised, v.Goal) + " of a " + gnot(v.Goal) + " goal")
 361	}
 362	b.WriteString("  \n**" + count(v.DonorCount, "donor", "donors") + "** · " + count(v.DonationCount, "gift", "gifts"))
 363	if v.RefundCount > 0 {
 364		b.WriteString(", " + count(v.RefundCount, "refund", "refunds"))
 365	}
 366	b.WriteString("  \n")
 367	switch {
 368	case v.Status == statusSettled:
 369		b.WriteString("**Completed** · closed on " + day(v.ClosedAt))
 370	case v.Status != statusOpen:
 371		b.WriteString("**Closed to new donations** · on " + day(v.ClosedAt))
 372	case v.Deadline != 0:
 373		b.WriteString("**" + left(v.Deadline) + "** · until " + day(v.Deadline))
 374	default:
 375		b.WriteString("**No deadline** · open until it is closed")
 376	}
 377	b.WriteString("\n\n")
 378}
 379
 380// writeMode writes the custody mode in words and what it means now (UI spec §4.3,
 381// ModeChip), with the refund link while a refund window is open to some donor — as
 382// far as the books can tell: after every donor's exit claim, rounding can leave a few
 383// ugnot that keep the window open in the books (API §2.4, §5).
 384func writeMode(b *strings.Builder, v CampaignView) {
 385	if v.Mode != modeEscrow {
 386		b.WriteString("**Direct**: each gift goes straight to the beneficiary, in the same transaction. Gifts can't be refunded.\n\n")
 387		return
 388	}
 389	b.WriteString("**Escrow**: ")
 390	switch {
 391	case v.Status != statusOpen && v.Balance == 0:
 392		b.WriteString("no gift is held any more.")
 393	case v.Status != statusOpen && now() >= v.ExitOpensAt: // the beneficiary's window has passed
 394		b.WriteString("the beneficiary didn't collect in time, so a donor who hasn't reclaimed their share yet can, at any time.")
 395	case v.EverWithdrawn:
 396		b.WriteString("the beneficiary has withdrawn funds, so gifts can't be refunded now.")
 397	default:
 398		b.WriteString("the gifts stay in escrow until the beneficiary withdraws them. Until then, a donor can take their gift back.")
 399	}
 400	if v.ExitOpen || !v.EverWithdrawn && v.Balance > 0 {
 401		b.WriteString(" [Refund](" + txlink.Realm(pkgPath).NewLink("Refund").AddArgs("id", v.ID).URL() + ")")
 402	}
 403	b.WriteString("\n\n")
 404}
 405
 406// writeBanner writes the one state banner the UI spec's state matrix gives the
 407// campaign now (§6), as gnoweb's alert box, which no user text can open (API §5): how
 408// it was closed and what remains to happen, that it completed, that Bolo is paused,
 409// that the escrow cap leaves no room for a gift, or that anyone may close it as it is
 410// dormant. space is the room the cap leaves.
 411func writeBanner(b *strings.Builder, v CampaignView, space int64, dormant bool) {
 412	kind, title, text := "NOTE", "", ""
 413	switch {
 414	case v.Status == statusSettled:
 415		kind, title = "SUCCESS", "Completed"
 416		text = "Closed to new donations on " + when(v.ClosedAt) + ", then settled: the organizer's deposit went back to them."
 417	case v.Status != statusOpen:
 418		title, text = "Closed to new donations", closedText(v)
 419	case paused:
 420		kind, title, text = "CAUTION", "Bolo is paused", "New gifts wait until it resumes. Withdrawals and refunds still work."
 421	case space < minDonation:
 422		title, text = "Full for now", "This campaign can't take more until the beneficiary withdraws."
 423		if escrowCap < minDonation { // no withdrawal makes room
 424			text = "Bolo's escrow cap is set below its smallest gift, so no campaign in escrow can take a gift for now."
 425		}
 426	case dormant:
 427		title, text = "Inactive", "No gift and no update for "+days(v.Params.DormancyPeriod)+": anyone can close it now."
 428	default:
 429		return
 430	}
 431	b.WriteString("> [!" + kind + "] " + title + "\n> " + text + "\n\n")
 432}
 433
 434// closedText says how a campaign was closed and, in escrow, what remains: the
 435// beneficiary's countdown to the exit window, or the window open to donors.
 436func closedText(v CampaignView) string {
 437	var out string
 438	switch v.CloseReason {
 439	case reasonDeadline:
 440		out = "It ended at its deadline, on " + when(v.ClosedAt) + "."
 441	case reasonDormant:
 442		out = "Closed on " + when(v.ClosedAt) + ", after " + days(v.Params.DormancyPeriod) + " with no gift and no update."
 443	default:
 444		out = "Closed by its organizer on " + when(v.ClosedAt) + "."
 445	}
 446	if v.Mode != modeEscrow || v.Balance == 0 {
 447		return out
 448	}
 449	if now() < v.ExitOpensAt {
 450		return out + " The beneficiary can withdraw the " + gnot(v.Balance) + " held until " + when(v.ExitOpensAt) + ", " + left(v.ExitOpensAt) + "; after that, donors can reclaim their share."
 451	}
 452	return out + " The beneficiary didn't collect in time: a donor who hasn't reclaimed their share yet can, at any time."
 453}
 454
 455// room is how much more campaign v can take: what the escrow cap leaves, in escrow
 456// with a cap (checkRoom); otherwise as much as an amount can be.
 457func room(v CampaignView) int64 {
 458	if v.Mode != modeEscrow || escrowCap == 0 {
 459		return math.MaxInt64
 460	}
 461	return escrowCap - v.Balance
 462}
 463
 464// writeGive writes the one-click donate links (API §5): while the campaign is open,
 465// listed and Bolo isn't paused, one per preset Donate would take — at least the
 466// smallest gift, and no more than the room the escrow cap leaves, space — then the
 467// page for another amount, with the room said when it left a preset out.
 468func writeGive(b *strings.Builder, v CampaignView, space int64) {
 469	if v.Status != statusOpen || !v.Listed || paused || space < minDonation {
 470		return
 471	}
 472	var links []string
 473	capped := false
 474	for _, preset := range presets {
 475		if preset > space {
 476			capped = true
 477			continue
 478		}
 479		if preset < minDonation {
 480			continue
 481		}
 482		links = append(links, giveLink(v.ID, preset))
 483	}
 484	links = append(links, "[Another amount]("+webPath+":c/"+v.ID+"/give)")
 485	b.WriteString("## Give\n\n" + strings.Join(links, " · ") + "\n\n")
 486	if capped {
 487		b.WriteString("It can take " + gnot(space) + " more for now: the beneficiary's next withdrawal makes room.\n\n")
 488	}
 489}
 490
 491// giveLink is a one-click link that gives ugnot to campaign id, with no tip and no
 492// message: gnoweb's form attaches the coins once the donor confirms them.
 493func giveLink(id string, ugnot int64) string {
 494	link := txlink.Realm(pkgPath).NewLink("Donate").
 495		AddArgs("id", id, "tip", "0", "message", "", "anonymous", "false").
 496		SetSend(strconv.FormatInt(ugnot, 10) + "ugnot").URL()
 497	return "[Give " + gnot(ugnot) + "](" + link + ")"
 498}
 499
 500// renderGive is the page for another amount (API §5). gnoweb's function form attaches
 501// only the coins a link carries, so this page asks the amount in gnoweb's own form,
 502// which submits back as ?amount=, and answers an amount Donate would take with its
 503// one-click link. Any other amount gets the form again with what the campaign can
 504// take, echoing nothing; a campaign Bolo offers no gift link for says so.
 505func renderGive(c *campaign, q map[string]string) string {
 506	v := campaignView(c)
 507	space := room(v)
 508	back := "[Back to the campaign](" + webPath + ":c/" + v.ID + ")\n"
 509	var b strings.Builder
 510	b.WriteString("# Give to [" + inline(v.Title) + "](" + webPath + ":c/" + v.ID + ")\n\n")
 511	if v.Status != statusOpen || !v.Listed || paused || space < minDonation {
 512		b.WriteString("Bolo offers no gift link for this campaign now: its page says why.\n\n" + back)
 513		return b.String()
 514	}
 515	raw, asked := q["amount"]
 516	if asked {
 517		if ugnot, ok := parseGNOT(raw); ok && ugnot >= minDonation && ugnot <= space {
 518			b.WriteString("**" + giveLink(v.ID, ugnot) + "**\n\n")
 519			b.WriteString("gnoweb's form asks you to confirm the coins before it adds them.\n\n" + back)
 520			return b.String()
 521		}
 522		b.WriteString("That isn't an amount this campaign can take.\n\n")
 523	}
 524	b.WriteString("Any amount from " + gnot(minDonation))
 525	if space < math.MaxInt64 {
 526		b.WriteString(" to " + gnot(space))
 527	}
 528	b.WriteString(", in GNOT.\n\n")
 529	b.WriteString("<gno-form path=\"c/" + v.ID + "/give\">\n  <gno-input name=\"amount\" placeholder=\"7.5\" />\n</gno-form>\n\n" + back)
 530	return b.String()
 531}
 532
 533// parseGNOT parses an amount in GNOT into ugnot (API §5): digits without a sign or a
 534// leading zero, at most 12 of them, then optionally a decimal point — "." or, as a
 535// form sends a comma, "%2C" — and one to six digits; but not a comma and exactly
 536// three, which may be a thousands separator. The spaces a form sends around a value,
 537// as "+", are dropped first.
 538func parseGNOT(s string) (int64, bool) {
 539	s = strings.Trim(s, "+")
 540	whole, frac, point := strings.Cut(s, ".")
 541	if !point {
 542		whole, frac, point = strings.Cut(s, "%2C")
 543		if point && len(frac) == 3 { // "1,000": a thousand, or one?
 544			return 0, false
 545		}
 546	}
 547	if whole == "" || len(whole) > 12 || len(whole) > 1 && whole[0] == '0' || point && (frac == "" || len(frac) > 6) {
 548		return 0, false
 549	}
 550	var n int64
 551	for i := 0; i < len(whole); i++ {
 552		if whole[i] < '0' || whole[i] > '9' {
 553			return 0, false
 554		}
 555		n = n*10 + int64(whole[i]-'0')
 556	}
 557	n *= 1_000_000
 558	unit := int64(100_000)
 559	for i := 0; i < len(frac); i++ {
 560		if frac[i] < '0' || frac[i] > '9' {
 561			return 0, false
 562		}
 563		n += int64(frac[i]-'0') * unit
 564		unit /= 10
 565	}
 566	return n, true
 567}
 568
 569// writeMoney writes where the money is, a table of amounts: given, then, in escrow,
 570// refunded, raised net of refunds, withdrawn and held; in direct mode, sent straight
 571// to the beneficiary.
 572func writeMoney(b *strings.Builder, v CampaignView) {
 573	b.WriteString("## Where the money is\n\n| | GNOT |\n|---|--:|\n")
 574	b.WriteString("| Given: " + count(v.DonationCount, "gift", "gifts") + " from " + count(v.DonorCount, "donor", "donors") + " | " + amount(v.Raised) + " |\n")
 575	if v.Mode != modeEscrow {
 576		b.WriteString("| Sent straight to the beneficiary | " + amount(v.Raised) + " |\n\n")
 577		return
 578	}
 579	b.WriteString("| Refunded to donors | " + amount(v.Refunded) + " |\n")
 580	b.WriteString("| **Raised**, net of refunds | **" + amount(v.NetRaised) + "** |\n")
 581	b.WriteString("| Withdrawn by the beneficiary | " + amount(v.Withdrawn) + " |\n")
 582	b.WriteString("| Held in escrow | " + amount(v.Balance) + " |\n\n")
 583}
 584
 585// writeVerify writes the verification block: where to check every figure on the chain
 586// itself (UI spec §2, level 3), each address in full.
 587func writeVerify(b *strings.Builder, v CampaignView) {
 588	b.WriteString("## Check it yourself\n\n")
 589	if v.Mode == modeEscrow {
 590		b.WriteString("- **Escrow** `" + v.EscrowAddress.String() + "`: its bank balance is the " + gnot(v.Balance) + " held, plus " + gnot(v.Untracked) + " sent there outside Bolo\n")
 591	}
 592	b.WriteString("- **Bond address** `" + v.BondAddress.String() + "`: the organizer's " + gnot(v.Deposit) + " deposit")
 593	if v.Status == statusSettled {
 594		b.WriteString(", returned when the campaign completed")
 595	} else {
 596		b.WriteString(", returned when the campaign completes")
 597	}
 598	switch {
 599	case v.Bond == 0:
 600	case v.BondSlashed:
 601		b.WriteString("; a " + gnot(v.Bond) + " bond, slashed")
 602	case v.Status != statusOpen && now() >= v.BondReleasableAt:
 603		b.WriteString("; a " + gnot(v.Bond) + " bond the organizer staked, whose hold ended " + when(v.BondReleasableAt) + ": no longer at stake, it goes back to the organizer")
 604	default:
 605		b.WriteString("; a " + gnot(v.Bond) + " bond the organizer staked, lost if the campaign is delisted for fraud — not a guarantee")
 606	}
 607	b.WriteString("\n- **Organizer** `" + v.Organizer.String() + "`\n- **Beneficiary** `" + v.Beneficiary.String() + "`\n\n")
 608	b.WriteString("Created " + when(v.CreatedAt))
 609	if v.Deadline != 0 {
 610		b.WriteString(" · deadline " + when(v.Deadline))
 611	}
 612	if v.ClosedAt != 0 {
 613		b.WriteString(" · closed " + when(v.ClosedAt))
 614	}
 615	b.WriteString("\n\n")
 616}
 617
 618// writeManage writes the actions of the organizer, the beneficiary and anyone, each
 619// with who may take it, when that call could succeed for someone (API §5): last, as
 620// the web app puts maintenance last (UI spec §4.3). anyone is whether CanClose lets
 621// anyone close it.
 622func writeManage(b *strings.Builder, v CampaignView, anyone bool) {
 623	id := v.ID
 624	var links []string
 625	if v.Mode == modeEscrow && v.Balance+v.Untracked > 0 && (v.Status == statusOpen || v.Status == statusClosed && now() < v.ExitOpensAt) {
 626		links = append(links, "[Withdraw]("+txlink.Realm(pkgPath).NewLink("Withdraw").AddArgs("id", id).URL()+"): the beneficiary")
 627	}
 628	if anyone || v.Status == statusOpen {
 629		who := "the organizer"
 630		if anyone {
 631			who = "anyone"
 632		}
 633		links = append(links, "[Close]("+txlink.Realm(pkgPath).NewLink("Close").AddArgs("id", id).URL()+"): "+who)
 634	}
 635	if ok, _ := CanFinalize(id); ok {
 636		links = append(links, "[Finalize]("+txlink.Realm(pkgPath).NewLink("Finalize").AddArgs("id", id).URL()+"): anyone")
 637	}
 638	links = append(links, "[Post an update]("+txlink.Realm(pkgPath).NewLink("PostUpdate").AddArgs("id", id).URL()+"): the organizer")
 639	b.WriteString("## Manage this campaign\n\n- " + strings.Join(links, "\n- ") + "\n")
 640}
 641
 642// writeGift writes one gift in two lines: its amount, and its message unless
 643// moderation hid it; then who gave it — a short address linking to its page, or
 644// anonymous — when, how much its donor was refunded since (INV-12), and its receipt.
 645func writeGift(b *strings.Builder, r ReceiptView) {
 646	b.WriteString("- **" + gnot(r.Amount) + "**")
 647	if r.MessageHidden {
 648		b.WriteString(" · message hidden by moderation")
 649	} else if r.Message != "" {
 650		b.WriteString(" · “" + inline(r.Message) + "”")
 651	}
 652	b.WriteString("  \n  ")
 653	if r.Anonymous {
 654		b.WriteString("Anonymous")
 655	} else {
 656		b.WriteString("from " + addrLink(r.Donor))
 657	}
 658	b.WriteString(" · " + when(r.At))
 659	if r.DonorRefunded > 0 {
 660		b.WriteString(" · **" + gnot(r.DonorRefunded) + " refunded since**")
 661	}
 662	b.WriteString(" · [receipt](" + webPath + ":c/" + r.CampaignID + "/r/" + strconv.FormatInt(r.Seq, 10) + ")\n")
 663}
 664
 665// renderReceipt is a gift's receipt: the shareable object (PRD §5). When its donor was
 666// refunded since, the refund banner comes first, so a refunded receipt never looks like
 667// a live gift (UI spec §4.5, INV-12).
 668func renderReceipt(c *campaign, seq int64) string {
 669	r := GetReceipt(idString(c.id), seq)
 670	v := campaignView(c)
 671	var b strings.Builder
 672	if r.DonorRefunded > 0 {
 673		b.WriteString("> [!CAUTION] Refunded since\n> " + gnot(r.DonorRefunded) + " went back to this donor after this gift.\n\n")
 674	}
 675	b.WriteString("# Gift receipt\n\n")
 676	b.WriteString("**" + gnot(r.Amount) + "** to [" + inline(v.Title) + "](" + webPath + ":c/" + v.ID + "), on " + when(r.At) + ".\n\n")
 677	b.WriteString("| Receipt | " + r.ID + " |\n|---|---|\n")
 678	if r.Anonymous {
 679		b.WriteString("| From | Anonymous |\n")
 680	} else {
 681		b.WriteString("| From | `" + r.Donor.String() + "` |\n")
 682	}
 683	if r.Tip > 0 {
 684		b.WriteString("| Tip to Bolo | " + gnot(r.Tip) + " |\n")
 685	}
 686	b.WriteString("| The campaign's total right after it | " + gnot(r.RaisedAfter) + " |\n\n")
 687	if r.MessageHidden {
 688		b.WriteString("Message: hidden by moderation.\n\n")
 689	} else if r.Message != "" {
 690		b.WriteString("Message: “" + inline(r.Message) + "”\n\n")
 691	}
 692	b.WriteString("## Where it went\n\n")
 693	if bar := progressBar(v.NetRaised, v.Goal, barWide); bar != "" {
 694		b.WriteString(bar + "\n\n")
 695	}
 696	b.WriteString("The campaign is " + strings.ToLower(statusWord(v.Status)) + ": " + gnot(v.NetRaised) + " raised net of refunds")
 697	if v.Goal > 0 {
 698		b.WriteString(", " + percent(v.NetRaised, v.Goal) + " of its " + gnot(v.Goal) + " goal")
 699	}
 700	b.WriteString(".\n\n")
 701	if v.Mode == modeEscrow {
 702		b.WriteString("- " + gnot(v.Withdrawn) + " withdrawn by the beneficiary, `" + v.Beneficiary.String() + "`\n")
 703		b.WriteString("- " + gnot(v.Balance) + " held in escrow, at `" + v.EscrowAddress.String() + "`\n\n")
 704	} else {
 705		b.WriteString("Every gift went straight to the beneficiary, `" + v.Beneficiary.String() + "`, in the same transaction.\n\n")
 706	}
 707	b.WriteString("[The campaign](" + webPath + ":c/" + v.ID + ") · [All gifts](" + webPath + ":c/" + v.ID + "/donations)")
 708	if v.Mode == modeEscrow {
 709		b.WriteString(" · [Money out](" + webPath + ":c/" + v.ID + "/ledger)")
 710	}
 711	b.WriteString("\n")
 712	return b.String()
 713}
 714
 715// renderDonations is a page of a campaign's gifts, newest first, under one line of
 716// totals.
 717func renderDonations(c *campaign, page int) string {
 718	id := idString(c.id)
 719	s := c.state
 720	var b strings.Builder
 721	b.WriteString("# Gifts to [" + inline(c.text.title) + "](" + webPath + ":c/" + id + ")\n\n")
 722	b.WriteString(count(s.donationCount, "gift", "gifts") + " from " + count(s.donorCount, "donor", "donors") + " · " + gnot(s.raised) + " given")
 723	if s.refunded > 0 {
 724		b.WriteString(" · " + gnot(s.refunded) + " refunded")
 725	}
 726	b.WriteString("\n\n")
 727	gifts := ListReceipts(id, page, listPage)
 728	for _, r := range gifts {
 729		writeGift(&b, r)
 730	}
 731	if len(gifts) == 0 {
 732		b.WriteString(empty(page, "No gifts yet.\n", "No gifts on this page.\n"))
 733	}
 734	writePager(&b, webPath+":c/"+id+"/donations", page, listPage, c.receipts.Size())
 735	return b.String()
 736}
 737
 738// renderLedger is a page of a campaign's money out of its escrow: withdrawals, then
 739// refunds, each newest first. In direct mode, it says that nothing goes out: Withdraw
 740// and Refund refuse a direct campaign, so neither list ever holds one.
 741func renderLedger(c *campaign, page int) string {
 742	id := idString(c.id)
 743	var b strings.Builder
 744	b.WriteString("# Money out of [" + inline(c.text.title) + "](" + webPath + ":c/" + id + ")\n\n")
 745	if c.mode != modeEscrow {
 746		b.WriteString("**Direct**: each gift went straight to the beneficiary, `" + c.beneficiary.String() + "`, when it was given. Nothing is held, so nothing goes out.\n\n")
 747		b.WriteString("[All gifts](" + webPath + ":c/" + id + "/donations)\n")
 748		return b.String()
 749	}
 750	b.WriteString("Every coin that left the escrow, `" + escrowAddr(c.id).String() + "`: to the beneficiary, or back to donors.\n\n")
 751	b.WriteString("## Withdrawals\n\n")
 752	if ws := ListWithdrawals(id, page, listPage); len(ws) > 0 {
 753		b.WriteString("| When (UTC) | GNOT | Outside Bolo | To |\n|---|--:|--:|---|\n")
 754		for _, w := range ws {
 755			b.WriteString("| " + stamp(w.At) + " | " + amount(w.Amount) + " | " + amount(w.Untracked) + " | `" + w.To.String() + "` |\n")
 756		}
 757		b.WriteString("\nOutside Bolo: coins sent to the escrow directly, which the withdrawal carried too.\n")
 758	} else {
 759		b.WriteString(empty(page, "None.\n", "None on this page.\n"))
 760	}
 761	b.WriteString("\n## Refunds\n\n")
 762	if rs := ListRefunds(id, page, listPage); len(rs) > 0 {
 763		b.WriteString("| When (UTC) | GNOT | How | To |\n|---|--:|---|---|\n")
 764		for _, r := range rs {
 765			to := "Anonymous"
 766			if r.Donor != "" {
 767				to = "`" + r.Donor.String() + "`"
 768			}
 769			how := "refund"
 770			if r.Kind == windowExit {
 771				how = "exit claim"
 772			}
 773			b.WriteString("| " + stamp(r.At) + " | " + amount(r.Amount) + " | " + how + " | " + to + " |\n")
 774		}
 775	} else {
 776		b.WriteString(empty(page, "None.\n", "None on this page.\n"))
 777	}
 778	n := c.withdrawals.Size()
 779	if m := c.refunds.Size(); m > n {
 780		n = m
 781	}
 782	writePager(&b, webPath+":c/"+id+"/ledger", page, listPage, n)
 783	return b.String()
 784}
 785
 786// renderUpdates is a page of a campaign's updates, newest first, each as a quote.
 787func renderUpdates(c *campaign, page int) string {
 788	id := idString(c.id)
 789	var b strings.Builder
 790	b.WriteString("# Updates on [" + inline(c.text.title) + "](" + webPath + ":c/" + id + ")\n\n")
 791	updates := ListUpdates(id, page, updatesPage)
 792	for _, u := range updates {
 793		b.WriteString("## " + when(u.At) + "\n")
 794		b.WriteString(sanitize.Blockquote(u.Body))
 795	}
 796	if len(updates) == 0 {
 797		b.WriteString(empty(page, "No updates yet.\n", "No updates on this page.\n"))
 798	}
 799	writePager(&b, webPath+":c/"+id+"/updates", page, updatesPage, c.updates.Size())
 800	return b.String()
 801}
 802
 803// empty says a page of a campaign's list has nothing on it: first on the list's first
 804// page, where it means the list is empty, later past it (API §5).
 805func empty(page int, first, later string) string {
 806	if page == 1 {
 807		return first
 808	}
 809	return later
 810}
 811
 812// writePager writes links to the pages before and after page, of n entries, size a
 813// page.
 814func writePager(b *strings.Builder, base string, page, size, n int) {
 815	var links []string
 816	if page > 1 {
 817		links = append(links, "[Newer]("+base+"?page="+strconv.Itoa(page-1)+")")
 818	}
 819	if page*size < n {
 820		links = append(links, "[Older]("+base+"?page="+strconv.Itoa(page+1)+")")
 821	}
 822	if len(links) > 0 {
 823		b.WriteString("\n" + strings.Join(links, " · ") + "\n")
 824	}
 825}
 826
 827// addressList returns the address page's list a path segment names, and its heading;
 828// nil for any other segment. They show listed campaigns only, as they promote (API §5).
 829func addressList(name string) (func(address, string, int) ([]CampaignView, string), string) {
 830	switch name {
 831	case "organized":
 832		return ListByOrganizer, "Organized"
 833	case "named":
 834		return ListByBeneficiary, "Named as beneficiary by an organizer"
 835	case "gave":
 836		return ListByDonor, "Gave to, without anonymity"
 837	}
 838	return nil, ""
 839}
 840
 841// renderAddress is the public address page: the short address as its title and the
 842// full one under it, then the 10 newest campaigns of each list, as cards, with "More"
 843// while a list goes on.
 844func renderAddress(addr address) string {
 845	var b strings.Builder
 846	b.WriteString("# `" + short(addr) + "` on Bolo\n\n")
 847	b.WriteString("`" + addr.String() + "`: the campaigns this address organized, is named beneficiary of, or gave to without anonymity. Campaigns hidden from listings and anonymous gifts aren't shown.\n\n")
 848	for _, name := range []string{"organized", "named", "gave"} {
 849		list, heading := addressList(name)
 850		b.WriteString("## " + heading + "\n\n")
 851		views, next := list(addr, "", addressSection)
 852		writeListed(&b, views, next != "")
 853		if next != "" {
 854			b.WriteString("[More](" + webPath + ":u/" + addr.String() + "/" + name + "?after=" + next + ")\n\n")
 855		}
 856	}
 857	return b.String()
 858}
 859
 860// renderAddressList is one of the address page's lists, 20 at a time by cursor.
 861func renderAddressList(addr address, name, heading string, list func(address, string, int) ([]CampaignView, string), after string) string {
 862	var b strings.Builder
 863	b.WriteString("# `" + short(addr) + "`: " + strings.ToLower(heading[:1]) + heading[1:] + "\n\n")
 864	b.WriteString("[All of `" + addr.String() + "` on Bolo](" + webPath + ":u/" + addr.String() + ")\n\n")
 865	views, next := list(addr, after, listPage)
 866	writeListed(&b, views, after != "" || next != "")
 867	if next != "" {
 868		b.WriteString("[More](" + webPath + ":u/" + addr.String() + "/" + name + "?after=" + next + ")\n")
 869	}
 870	return b.String()
 871}
 872
 873// writeListed writes the cards of the listed campaigns among views: a delisted one is
 874// left out of a list that promotes. With none to show, it says so, and whether this is
 875// only one page of the list: a later one, or one a "More" follows (paged).
 876func writeListed(b *strings.Builder, views []CampaignView, paged bool) {
 877	var listed []CampaignView
 878	for _, v := range views {
 879		if v.Listed {
 880			listed = append(listed, v)
 881		}
 882	}
 883	switch {
 884	case len(listed) > 0:
 885		writeCards(b, listed)
 886	case paged:
 887		b.WriteString("None on this page.\n\n")
 888	default:
 889		b.WriteString("None.\n\n")
 890	}
 891}
 892
 893// renderLookup is the page that finds an address: gnoweb's form, which submits back
 894// here as ?address=. A valid address gets its page; anything else, the form again with
 895// one fixed line, echoing nothing from the request (API §5).
 896func renderLookup(q map[string]string) string {
 897	raw, asked := q["address"]
 898	if asked {
 899		if addr, ok := parseAddress(raw); ok {
 900			return renderAddress(addr)
 901		}
 902	}
 903	var b strings.Builder
 904	b.WriteString("# Find an address\n\n")
 905	if asked {
 906		b.WriteString("That isn't a gno.land address.\n\n")
 907	}
 908	b.WriteString("See what an address has done on Bolo: the campaigns it organized, is named beneficiary of, or gave to without anonymity.\n\n")
 909	b.WriteString("<gno-form path=\"u\">\n  <gno-input name=\"address\" placeholder=\"g1…\" />\n</gno-form>\n")
 910	return b.String()
 911}
 912
 913// renderAbout is the guarantees table, what Bolo costs, where tips go, how to check it,
 914// and what not to do (PRD §3, UI spec §4.11).
 915func renderAbout() string {
 916	return "# What Bolo guarantees, and what it doesn't\n\n" +
 917		"| A donor can verify themselves | A donor must still take on faith |\n|---|---|\n" +
 918		"| The exact amount the campaign has received | That the campaign's story is true |\n" +
 919		"| Every payout: amount, destination, timestamp | That the beneficiary spends it as promised |\n" +
 920		"| That no fee was taken | That the person in the photo consented to it |\n" +
 921		"| That the organizer cannot quietly change the beneficiary | That the organizer is who they claim to be |\n" +
 922		"| That their own donation arrived and is counted | |\n" +
 923		"| That nothing in Bolo, and nobody running it, can freeze or seize it | |\n\n" +
 924		"The last row is scoped to Bolo: the gno.land chain's own governance sits above every application on it.\n\n" +
 925		"**A chain fixes custody fraud. It does not fix claim fraud.** \"Verified on-chain\" never means \"verified true\".\n\n" +
 926		"## Fees\n\n0% platform fee. Your wallet pays a small network fee. Tips to Bolo are optional.\n\n" +
 927		"## Where tips go\n\nTo `" + tipRecipient.String() + "`, in the same transaction as your gift: Bolo keeps no treasury.\n\n" +
 928		"## How to check\n\nEvery campaign page lists its escrow and bond addresses: read their balances on any explorer, and every transaction behind each figure. The code doing all of this is public, at `" + pkgPath + "`, and nobody can change it.\n\n" +
 929		"## Don't send coins to Bolo's addresses directly\n\nGive through a campaign's page. Coins sent straight to this version's own address can be swept to the tip recipient; coins sent to the version registry can't be recovered.\n\n" +
 930		"[Bolo](" + webPath + ")\n"
 931}
 932
 933// renderParams shows the parameters new campaigns get, the live settings in words,
 934// and the admin.
 935func renderParams() string {
 936	s := GetSettings()
 937	p := params
 938	a, pending := GetAdmin()
 939	var b strings.Builder
 940	b.WriteString("# Parameters\n\n## For new campaigns\n\n")
 941	b.WriteString("- Creation deposit: " + gnot(p.CreationDeposit) + "\n")
 942	b.WriteString("- Dormancy: " + days(p.DormancyPeriod) + "; exit delay: " + days(p.ExitDelay) + "; bond hold: " + days(p.BondHold) + "\n\n")
 943	b.WriteString("## Settings\n\n")
 944	if s.Paused {
 945		b.WriteString("- Gifts: paused — withdrawals and refunds still work\n")
 946	} else {
 947		b.WriteString("- Gifts: accepted\n")
 948	}
 949	switch {
 950	case s.Sunset:
 951		b.WriteString("- New campaigns: not on this version\n")
 952	case s.Paused:
 953		b.WriteString("- New campaigns: paused\n")
 954	default:
 955		b.WriteString("- New campaigns: accepted\n")
 956	}
 957	b.WriteString("- Smallest gift: " + gnot(s.MinDonation) + "\n")
 958	b.WriteString("- One-click amounts: ")
 959	for i, preset := range s.Presets {
 960		if i > 0 {
 961			b.WriteString(" · ")
 962		}
 963		b.WriteString(gnot(preset))
 964	}
 965	b.WriteString("\n- Escrow cap: ")
 966	if s.MaxEscrowBalance == 0 {
 967		b.WriteString("none")
 968	} else {
 969		b.WriteString(gnot(s.MaxEscrowBalance))
 970	}
 971	b.WriteString("\n- Tips go to: `" + s.TipRecipient.String() + "`\n")
 972	b.WriteString("- Categories: " + strings.Join(s.Categories, ", ") + "\n\n")
 973	b.WriteString("## Admin\n\n`" + a.String() + "`")
 974	if pending != "" {
 975		b.WriteString(", handing over to `" + pending.String() + "`")
 976	}
 977	b.WriteString("\n")
 978	return b.String()
 979}
 980
 981// progressBar draws how much of its goal a campaign raised (UI spec §5, ProgressBar):
 982// an SVG image of two numbers, which gnoweb accepts inline as a data URI, width wide.
 983// It fills min(net, goal) of goal; its text, the percentage, is uncapped. With no goal,
 984// there is no bar.
 985func progressBar(net, goal int64, width int) string {
 986	if goal <= 0 {
 987		return ""
 988	}
 989	filled := net
 990	if filled > goal {
 991		filled = goal
 992	}
 993	w := strconv.Itoa(width)
 994	return "![" + percent(net, goal) + " of the goal](data:image/svg+xml;utf8," +
 995		"%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='" + w + "'%20height='10'%20viewBox='0%200%20" + w + "%2010'%3E" +
 996		"%3Crect%20width='" + w + "'%20height='10'%20rx='5'%20fill='%23888'%20fill-opacity='.25'/%3E" +
 997		"%3Crect%20width='" + strconv.FormatInt(share(filled, goal, int64(width)), 10) + "'%20height='10'%20rx='5'%20fill='%23E4A11B'/%3E" +
 998		"%3C/svg%3E)"
 999}
1000
1001// percent writes net as a share of goal, rounded down and uncapped: "124%".
1002func percent(net, goal int64) string {
1003	return strconv.FormatInt(share(net, goal, 100), 10) + "%"
1004}
1005
1006// share is part×scale/whole rounded down, for whole > 0, and 0 for part ≤ 0: exact for
1007// any part up to math.MaxInt64/scale, past every amount of ugnot there is, and held
1008// there beyond, so the product never overflows.
1009func share(part, whole, scale int64) int64 {
1010	if part <= 0 {
1011		return 0
1012	}
1013	if part > math.MaxInt64/scale {
1014		part = math.MaxInt64 / scale
1015	}
1016	return part * scale / whole
1017}
1018
1019// short shortens an address where a reader recognizes it rather than checks it: its
1020// first eight and last six characters (UI spec §4.13).
1021func short(a address) string {
1022	s := a.String()
1023	if len(s) <= 14 {
1024		return s
1025	}
1026	return s[:8] + "…" + s[len(s)-6:]
1027}
1028
1029// addrLink links an address's short form, in a code span, to its address page: Bolo's
1030// own link, where gnoweb makes no mention (API §5).
1031func addrLink(a address) string {
1032	return "[`" + short(a) + "`](" + webPath + ":u/" + a.String() + ")"
1033}
1034
1035// gnot writes an amount of ugnot in GNOT: up to six decimals, trailing zeros dropped.
1036func gnot(ugnot int64) string {
1037	return amount(ugnot) + " GNOT"
1038}
1039
1040// amount writes an amount of ugnot as a number of GNOT, for a column headed GNOT.
1041func amount(ugnot int64) string {
1042	sign := ""
1043	if ugnot < 0 {
1044		sign, ugnot = "-", -ugnot
1045	}
1046	whole, frac := ugnot/1_000_000, ugnot%1_000_000
1047	out := sign + strconv.FormatInt(whole, 10)
1048	if frac != 0 {
1049		f := strconv.FormatInt(frac+1_000_000, 10)[1:]
1050		out += "." + strings.TrimRight(f, "0")
1051	}
1052	return out
1053}
1054
1055// when writes a Unix time in UTC.
1056func when(unix int64) string {
1057	return stamp(unix) + " UTC"
1058}
1059
1060// stamp writes a Unix time in UTC without saying so, for a column that does.
1061func stamp(unix int64) string {
1062	return time.Unix(unix, 0).UTC().Format("2006-01-02 15:04")
1063}
1064
1065// day writes a Unix time's day in UTC, where the minute is written nearby: a figure's
1066// caption, under the full time in the dates line.
1067func day(unix int64) string {
1068	return time.Unix(unix, 0).UTC().Format("2006-01-02")
1069}
1070
1071// left writes a real timer's countdown (UI spec §5, Countdown): whole days left, then
1072// "less than a day left".
1073func left(until int64) string {
1074	d := (until - now()) / secondsPerDay
1075	if d < 1 {
1076		return "less than a day left"
1077	}
1078	return count(int(d), "day left", "days left")
1079}
1080
1081// count writes n of something: one, or many.
1082func count(n int, one, many string) string {
1083	if n == 1 {
1084		return "1 " + one
1085	}
1086	return strconv.Itoa(n) + " " + many
1087}
1088
1089// days writes a duration in seconds as whole days.
1090func days(seconds int64) string {
1091	return strconv.FormatInt(seconds/secondsPerDay, 10) + " days"
1092}
1093
1094// statusWord names a status in the UI spec's words (§8.1).
1095func statusWord(status string) string {
1096	switch status {
1097	case statusOpen:
1098		return "Open"
1099	case statusClosed:
1100		return "Closed to new donations"
1101	}
1102	return "Completed"
1103}
1104
1105// modeWord names a custody mode in the UI spec's words (§8.1).
1106func modeWord(mode string) string {
1107	if mode == modeEscrow {
1108		return "Escrow"
1109	}
1110	return "Direct"
1111}
1112
1113// inline escapes text for an inline slot: sanitize.InlineText, which leaves no link and
1114// no image, then the mentions gnoweb would still make links of — "@name", and an
1115// address after a space — each to a page whose owner writes it (API §5). A backslash
1116// keeps "@" as it reads; a no-break space looks like the space it replaces.
1117func inline(s string) string {
1118	s = strings.ReplaceAll(sanitize.InlineText(s), "@", `\@`)
1119	return strings.ReplaceAll(s, " g1", "\u00a0g1")
1120}
1121
1122// lineStart escapes text for an inline slot that opens a line — the title's heading,
1123// the notice's line — where gnoweb also links an address with nothing before it:
1124// "&#103;" reads as the "g" it replaces. Only there: gnoweb's table of contents shows a
1125// card's heading as written, and a card's title sits in a link's text, where gnoweb
1126// makes no mention (API §5).
1127func lineStart(s string) string {
1128	s = inline(s)
1129	if strings.HasPrefix(s, "g1") {
1130		s = "&#103;" + s[1:]
1131	}
1132	return s
1133}