todos.gno
3.50 Kb · 150 lines
1// Package todos is a shared, on-chain to-do board realm.
2//
3// Anyone can add an item; the caller's address is recorded as the author.
4// Items can be toggled (open/done) or removed. Render exposes the board as
5// Markdown checkboxes with open/done counts, newest item first.
6package todos
7
8import (
9 "strconv"
10 "strings"
11
12 "gno.land/p/nt/avl/v0"
13)
14
15// item is one entry on the board. `address` is the uverse bech32 type — it is
16// persistable (unlike a `realm` value), so it is safe to store across txs.
17type item struct {
18 id int
19 text string
20 done bool
21 author address
22}
23
24var (
25 // items is keyed by zero-padded id string so avl iteration is ordered by id.
26 items avl.Tree
27 nextID int
28)
29
30// Add appends a new item authored by the caller and returns its id.
31//
32// Crossing function: takes `cur realm` first (gno 0.9 interrealm convention),
33// authenticates with cur.IsCurrent() before trusting cur.Previous().
34func Add(cur realm, text string) int {
35 if !cur.IsCurrent() {
36 panic("spoofed realm")
37 }
38 text = strings.TrimSpace(text)
39 if text == "" {
40 panic("todos: text must not be empty")
41 }
42
43 author := cur.Previous().Address()
44
45 nextID++
46 id := nextID
47 items.Set(idKey(id), &item{
48 id: id,
49 text: text,
50 done: false,
51 author: author,
52 })
53 return id
54}
55
56// Toggle flips the done state of the item with the given id.
57func Toggle(cur realm, id int) {
58 if !cur.IsCurrent() {
59 panic("spoofed realm")
60 }
61 v, ok := items.Get(idKey(id))
62 if !ok {
63 panic("todos: item not found: #" + strconv.Itoa(id))
64 }
65 it := v.(*item)
66 it.done = !it.done
67}
68
69// Remove deletes the item with the given id.
70func Remove(cur realm, id int) {
71 if !cur.IsCurrent() {
72 panic("spoofed realm")
73 }
74 if _, removed := items.Remove(idKey(id)); !removed {
75 panic("todos: item not found: #" + strconv.Itoa(id))
76 }
77}
78
79// idKey renders an id as a fixed-width, zero-padded key so avl.Tree's
80// byte-lexicographic ordering matches numeric id ordering.
81func idKey(id int) string {
82 s := strconv.Itoa(id)
83 const width = 12
84 if len(s) >= width {
85 return s
86 }
87 return strings.Repeat("0", width-len(s)) + s
88}
89
90// shortAddr renders an address as "g1abc…" (first 6 chars + ellipsis).
91func shortAddr(a address) string {
92 s := a.String()
93 if len(s) <= 6 {
94 return s
95 }
96 return s[:6] + "…"
97}
98
99// counts returns the number of open and done items.
100func counts() (open, done int) {
101 items.Iterate("", "", func(_ string, v interface{}) bool {
102 if v.(*item).done {
103 done++
104 } else {
105 open++
106 }
107 return false
108 })
109 return open, done
110}
111
112// Render returns the board as Markdown. Newest item (highest id) first.
113//
114// Render is NOT a crossing function (no `cur realm` param) — it is read-only
115// and invoked by gnoweb, not via MsgCall.
116func Render(path string) string {
117 open, done := counts()
118
119 var b strings.Builder
120 b.WriteString("# Shared To-Do Board\n\n")
121 b.WriteString(strconv.Itoa(open) + " open · ")
122 b.WriteString(strconv.Itoa(done) + " done · ")
123 b.WriteString(strconv.Itoa(open+done) + " total\n\n")
124
125 if open+done == 0 {
126 b.WriteString("_No items yet. Add one with `Add(text)`._\n")
127 return b.String()
128 }
129
130 // Collect in ascending id order, then emit newest first.
131 var lines []string
132 items.Iterate("", "", func(_ string, v interface{}) bool {
133 it := v.(*item)
134 mark := " "
135 if it.done {
136 mark = "x"
137 }
138 line := "- [" + mark + "] #" + strconv.Itoa(it.id) + " " +
139 it.text + " (" + shortAddr(it.author) + ")"
140 lines = append(lines, line)
141 return false
142 })
143
144 for i := len(lines) - 1; i >= 0; i-- {
145 b.WriteString(lines[i])
146 b.WriteString("\n")
147 }
148
149 return b.String()
150}