folders.gno
14.68 Kb · 626 lines
1package kourtv3
2import (
3 "strconv"
4 "strings"
5 bptree "gno.land/p/nt/bptree/v0"
6)
7const maxFolderTextLen = 200
8const (
9 maxFolderDepth = 4
10 maxFolderItems = 200
11 maxFolders = 100
12)
13type folder struct {
14 id uint64
15 name string
16 desc string
17 items []uint64
18 purged bool
19 code string
20 retired bool
21 bornOf uint64
22 focusByDefault bool
23 parent uint64
24 ord int
25 image *mediaItem
26}
27func (cm *courtMod) folderDepth(id uint64) int {
28 d := 0
29 for id != 0 && d <= maxFolders {
30 d++
31 v := cm.folders.Get(beClaimKey(id))
32 if v == nil {
33 break
34 }
35 id = v.(*folder).parent
36 }
37 return d
38}
39func (cm *courtMod) folderHeight(id uint64) int {
40 h := 1
41 cm.folders.Iterate("", "", func(_ string, v any) bool {
42 f, ok := v.(*folder)
43 if !ok || f.parent != id {
44 return false
45 }
46 if ch := 1 + cm.folderHeight(f.id); ch > h {
47 h = ch
48 }
49 return false
50 })
51 return h
52}
53func (cm *courtMod) mustNestable(id, parent uint64, height int) {
54 if parent == 0 {
55 return
56 }
57 if parent == id {
58 panic("kourtv3: a folder cannot contain itself")
59 }
60 cm.mustFolder(parent)
61 for up, n := parent, 0; up != 0 && n <= maxFolders; n++ {
62 if up == id {
63 panic("kourtv3: that would put a folder inside itself")
64 }
65 v := cm.folders.Get(beClaimKey(up))
66 if v == nil {
67 break
68 }
69 up = v.(*folder).parent
70 }
71 if cm.folderDepth(parent)+height > maxFolderDepth {
72 panic("kourtv3: folders nest at most " + strconv.Itoa(maxFolderDepth) + " deep")
73 }
74}
75func folderAfter(a, b *folder) bool {
76 ap, bp := a.ord > 0, b.ord > 0
77 if ap != bp {
78 return bp
79 }
80 if ap && a.ord != b.ord {
81 return a.ord > b.ord
82 }
83 return a.id > b.id
84}
85func (cm *courtMod) folderRows() []*folder {
86 out := make([]*folder, 0, maxFolders)
87 cm.folders.Iterate("", "", func(_ string, v any) bool {
88 if f, ok := v.(*folder); ok {
89 out = append(out, f)
90 }
91 return false
92 })
93 for i := range out {
94 first := true
95 for j := 0; j < i; j++ {
96 if out[j].parent == out[i].parent {
97 first = false
98 break
99 }
100 }
101 if !first {
102 continue
103 }
104 p := out[i].parent
105 slots := make([]int, 0, len(out))
106 grp := make([]*folder, 0, len(out))
107 for j, g := range out {
108 if g.parent == p {
109 slots = append(slots, j)
110 grp = append(grp, g)
111 }
112 }
113 for a := 1; a < len(grp); a++ {
114 f := grp[a]
115 b := a - 1
116 for b >= 0 && folderAfter(grp[b], f) {
117 grp[b+1] = grp[b]
118 b--
119 }
120 grp[b+1] = f
121 }
122 for k, g := range grp {
123 out[slots[k]] = g
124 }
125 }
126 return out
127}
128func (cm *courtMod) mustFolder(id uint64) *folder {
129 v := cm.folders.Get(beClaimKey(id))
130 if v == nil {
131 panic("kourtv3: no such folder")
132 }
133 return v.(*folder)
134}
135func requireFolderMod(c *Court, who address) *courtMod {
136 cm := ensureMod(c)
137 requireActiveMod(cm, who)
138 return cm
139}
140func folderTextOK(name, desc string) bool {
141 return runeLen(name) > 0 && runeLen(name) <= maxFolderTextLen && runeLen(desc) <= maxFolderTextLen
142}
143func folderNameTaken(cm *courtMod, name string) bool {
144 taken := false
145 if cm.folders == nil {
146 return false
147 }
148 cm.folders.Iterate("", "", func(_ string, v any) bool {
149 f := v.(*folder)
150 if !f.retired && f.name == name {
151 taken = true
152 return true
153 }
154 return false
155 })
156 return taken
157}
158func newFolderCore(cm *courtMod, name, desc string, parent uint64) uint64 {
159 if cm.folders.Size() >= maxFolders {
160 panic("kourtv3: this court already has " + strconv.Itoa(maxFolders) + " folders")
161 }
162 if !folderTextOK(name, desc) {
163 panic("kourtv3: a folder name is 1..200 and a description at most 200 characters")
164 }
165 if parent != 0 {
166 cm.mustNestable(0, parent, 1)
167 }
168 cm.folderSeq++
169 id := cm.folderSeq
170 cm.folders.Set(beClaimKey(id), &folder{id: id, name: name, desc: desc, parent: parent})
171 return id
172}
173type folderProposal struct {
174 id uint64
175 name string
176 desc string
177 parent uint64
178 by address
179 at int64
180}
181func RegisterFolderProposal(cur realm, courtSlug, name, desc string, parentID uint64) uint64 {
182 if !cur.IsCurrent() {
183 panic(errStaleRealm)
184 }
185 who := cur.Previous().Address()
186 c := mustCourt(courtSlug)
187 cm := ensureMod(c)
188 if !folderTextOK(name, desc) {
189 panic("kourtv3: a folder name is 1..200 and a description at most 200 characters")
190 }
191 if parentID != 0 {
192 cm.mustNestable(0, parentID, 1)
193 }
194 if folderNameTaken(cm, name) {
195 panic("kourtv3: this court already has a folder by that name")
196 }
197 if cm.folderProps == nil {
198 cm.folderProps = bptree.NewBPTree32()
199 }
200 cm.folderPropSeq++
201 id := cm.folderPropSeq
202 cm.folderProps.Set(beClaimKey(id), &folderProposal{
203 id: id, name: name, desc: desc, parent: parentID, by: who, at: heightNow(),
204 })
205 return id
206}
207func (cm *courtMod) mustFolderProposal(id uint64) *folderProposal {
208 if cm.folderProps == nil {
209 panic("kourtv3: no such folder proposal")
210 }
211 v := cm.folderProps.Get(beClaimKey(id))
212 if v == nil {
213 panic("kourtv3: no such folder proposal")
214 }
215 return v.(*folderProposal)
216}
217func CreateFolder(cur realm, courtSlug, name, desc string) uint64 {
218 if !cur.IsCurrent() {
219 panic(errStaleRealm)
220 }
221 who := cur.Previous().Address()
222 c := mustCourt(courtSlug)
223 cm := requireFolderMod(c, who)
224 id := newFolderCore(cm, name, desc, 0)
225 emitModAct(c.id, 0, "folder-create", who)
226 return id
227}
228func CreateFolderIn(cur realm, courtSlug string, parentID uint64, name, desc string) uint64 {
229 if !cur.IsCurrent() {
230 panic(errStaleRealm)
231 }
232 who := cur.Previous().Address()
233 c := mustCourt(courtSlug)
234 cm := requireFolderMod(c, who)
235 id := newFolderCore(cm, name, desc, parentID)
236 emitModAct(c.id, 0, "folder-create", who)
237 return id
238}
239func MoveFolder(cur realm, courtSlug string, folderID, newParent uint64) {
240 if !cur.IsCurrent() {
241 panic(errStaleRealm)
242 }
243 who := cur.Previous().Address()
244 c := mustCourt(courtSlug)
245 cm := requireFolderMod(c, who)
246 f := cm.mustFolder(folderID)
247 cm.mustNestable(folderID, newParent, cm.folderHeight(folderID))
248 f.parent = newParent
249 emitModAct(c.id, 0, "folder-move", who)
250}
251func AddToFolder(cur realm, courtSlug string, folderID, claimID uint64) {
252 if !cur.IsCurrent() {
253 panic(errStaleRealm)
254 }
255 who := cur.Previous().Address()
256 c := mustCourt(courtSlug)
257 cm := requireFolderMod(c, who)
258 _ = mustClaim(c, claimID)
259 f := cm.mustFolder(folderID)
260 fileInto(f, claimID)
261 emitModAct(c.id, claimID, "folder-add", who)
262}
263func fileInto(f *folder, claimID uint64) {
264 for _, x := range f.items {
265 if x == claimID {
266 return
267 }
268 }
269 if len(f.items) >= maxFolderItems {
270 panic("kourtv3: a folder holds at most " + strconv.Itoa(maxFolderItems) + " claims")
271 }
272 f.items = append(f.items, claimID)
273}
274func RemoveFromFolder(cur realm, courtSlug string, folderID, claimID uint64) {
275 if !cur.IsCurrent() {
276 panic(errStaleRealm)
277 }
278 who := cur.Previous().Address()
279 c := mustCourt(courtSlug)
280 cm := requireFolderMod(c, who)
281 f := cm.mustFolder(folderID)
282 out := f.items[:0]
283 found := false
284 for _, x := range f.items {
285 if x == claimID {
286 found = true
287 continue
288 }
289 out = append(out, x)
290 }
291 if !found {
292 panic("kourtv3: that claim is not in the folder")
293 }
294 f.items = out
295 emitModAct(c.id, claimID, "folder-remove", who)
296}
297func RenameFolder(cur realm, courtSlug string, folderID uint64, name, desc string) {
298 if !cur.IsCurrent() {
299 panic(errStaleRealm)
300 }
301 who := cur.Previous().Address()
302 c := mustCourt(courtSlug)
303 cm := requireFolderMod(c, who)
304 if runeLen(name) == 0 || runeLen(name) > maxFolderTextLen || runeLen(desc) > maxFolderTextLen {
305 panic("kourtv3: a folder name is 1..200 and a description at most 200 characters")
306 }
307 f := cm.mustFolder(folderID)
308 if f.purged {
309 panic("kourtv3: this folder's text was purged and cannot be re-set")
310 }
311 f.name = name
312 f.desc = desc
313 emitModAct(c.id, 0, "folder-rename", who)
314}
315func SetFolderImage(cur realm, courtSlug string, folderID uint64, media string) {
316 if !cur.IsCurrent() {
317 panic(errStaleRealm)
318 }
319 who := cur.Previous().Address()
320 c := mustCourt(courtSlug)
321 cm := requireFolderMod(c, who)
322 f := cm.mustFolder(folderID)
323 if f.purged {
324 panic("kourtv3: this folder's text was purged and cannot be re-set")
325 }
326 items := parseMediaArg(media)
327 if len(items) != 1 {
328 panic("kourtv3: a folder carries exactly one image")
329 }
330 if items[0].kind != mediaKindImage {
331 panic("kourtv3: a folder's image is an image, not a video link")
332 }
333 f.image = &items[0]
334 emitModAct(c.id, 0, "folder-image", who)
335}
336func ClearFolderImage(cur realm, courtSlug string, folderID uint64) {
337 if !cur.IsCurrent() {
338 panic(errStaleRealm)
339 }
340 who := cur.Previous().Address()
341 c := mustCourt(courtSlug)
342 cm := requireFolderMod(c, who)
343 cm.mustFolder(folderID).image = nil
344 emitModAct(c.id, 0, "folder-image-clear", who)
345}
346func FolderImage(courtSlug string, folderID uint64) string {
347 c := mustCourt(courtSlug)
348 cm := c.mod
349 if cm == nil {
350 return "[]"
351 }
352 f := cm.mustFolder(folderID)
353 if f.image == nil {
354 return "[]"
355 }
356 return encodeMedia([]mediaItem{*f.image})
357}
358func PurgeFolder(cur realm, courtSlug string, folderID uint64, categoryCode string) {
359 if !cur.IsCurrent() {
360 panic(errStaleRealm)
361 }
362 who := cur.Previous().Address()
363 d := ensureGlobalDAO()
364 if !d.members.Has(who.String()) {
365 panic("kourtv3: only a global DAO member may purge")
366 }
367 mustCategoryCode(categoryCode)
368 c := mustCourt(courtSlug)
369 cm := ensureMod(c)
370 f := cm.mustFolder(folderID)
371 fire, code := approveAction(d.pending, "purgefolder:"+c.id+":"+strconv.FormatUint(folderID, 10), who, categoryCode, d.purgeM)
372 if !fire {
373 return
374 }
375 f.purged = true
376 f.code = code
377 f.name = ""
378 f.desc = ""
379 f.image = nil
380 emitPurge(c.id, 0, code, who)
381}
382func FolderCount(courtSlug string) int {
383 c := mustCourt(courtSlug)
384 if c.mod == nil {
385 return 0
386 }
387 return c.mod.folders.Size()
388}
389func FolderName(courtSlug string, folderID uint64) string {
390 c := mustCourt(courtSlug)
391 if c.mod == nil {
392 panic("kourtv3: no such folder")
393 }
394 f := c.mod.mustFolder(folderID)
395 if f.purged {
396 return "[purged:" + f.code + "]"
397 }
398 return f.name
399}
400func FolderDesc(courtSlug string, folderID uint64) string {
401 c := mustCourt(courtSlug)
402 if c.mod == nil {
403 panic("kourtv3: no such folder")
404 }
405 return c.mod.mustFolder(folderID).desc
406}
407func FolderItems(courtSlug string, folderID uint64) []uint64 {
408 c := mustCourt(courtSlug)
409 if c.mod == nil {
410 panic("kourtv3: no such folder")
411 }
412 f := c.mod.mustFolder(folderID)
413 out := make([]uint64, len(f.items))
414 copy(out, f.items)
415 return out
416}
417func ClaimFolders(courtSlug string, claimID uint64) []uint64 {
418 c := mustCourt(courtSlug)
419 if c.mod == nil {
420 return nil
421 }
422 var out []uint64
423 for _, f := range c.mod.folderRows() {
424 if f.purged {
425 continue
426 }
427 for _, it := range f.items {
428 if it == claimID {
429 out = append(out, f.id)
430 break
431 }
432 }
433 }
434 return out
435}
436func RetireFolder(cur realm, courtSlug string, folderID uint64) {
437 if !cur.IsCurrent() {
438 panic(errStaleRealm)
439 }
440 who := cur.Previous().Address()
441 c := mustCourt(courtSlug)
442 cm := requireFolderMod(c, who)
443 f := cm.mustFolder(folderID)
444 if cm.folderHeight(folderID) > 1 {
445 panic("kourtv3: retire the folders inside it first")
446 }
447 f.retired = true
448 f.items = nil
449 emitModAct(c.id, 0, "folder-retire", who)
450}
451func RestoreFolder(cur realm, courtSlug string, folderID uint64) {
452 if !cur.IsCurrent() {
453 panic(errStaleRealm)
454 }
455 who := cur.Previous().Address()
456 c := mustCourt(courtSlug)
457 cm := requireFolderMod(c, who)
458 f := cm.mustFolder(folderID)
459 if f.purged {
460 panic("kourtv3: a purged folder cannot be restored")
461 }
462 f.retired = false
463 emitModAct(c.id, 0, "folder-restore", who)
464}
465func MoveItemInFolder(cur realm, courtSlug string, folderID, claimID uint64, to int) {
466 if !cur.IsCurrent() {
467 panic(errStaleRealm)
468 }
469 who := cur.Previous().Address()
470 c := mustCourt(courtSlug)
471 cm := requireFolderMod(c, who)
472 f := cm.mustFolder(folderID)
473 at := -1
474 for i, x := range f.items {
475 if x == claimID {
476 at = i
477 break
478 }
479 }
480 if at < 0 {
481 panic("kourtv3: that claim is not in the folder")
482 }
483 if to < 0 || to >= len(f.items) {
484 panic("kourtv3: that position is outside the folder")
485 }
486 if to == at {
487 return
488 }
489 out := make([]uint64, 0, len(f.items))
490 for i, x := range f.items {
491 if i == at {
492 continue
493 }
494 if len(out) == to {
495 out = append(out, claimID)
496 }
497 out = append(out, x)
498 }
499 if len(out) == to {
500 out = append(out, claimID)
501 }
502 f.items = out
503 emitModAct(c.id, claimID, "folder-order", who)
504}
505func OrderFolders(cur realm, courtSlug string, parentID uint64, ids string) {
506 if !cur.IsCurrent() {
507 panic(errStaleRealm)
508 }
509 who := cur.Previous().Address()
510 c := mustCourt(courtSlug)
511 cm := requireFolderMod(c, who)
512 if parentID != 0 {
513 cm.mustFolder(parentID)
514 }
515 if len(ids) > maxFolders*21 {
516 panic("kourtv3: that is too long to be a list of folder ids")
517 }
518 sibs := make([]*folder, 0, maxFolders)
519 cm.folders.Iterate("", "", func(_ string, v any) bool {
520 if f, ok := v.(*folder); ok && f.parent == parentID {
521 sibs = append(sibs, f)
522 }
523 return false
524 })
525 if len(sibs) == 0 {
526 panic("kourtv3: there are no folders under that heading to order")
527 }
528 parts := strings.Split(ids, ",")
529 if len(parts) != len(sibs) {
530 panic("kourtv3: that heading holds " + strconv.Itoa(len(sibs)) +
531 " folders and the order lists " + strconv.Itoa(len(parts)) +
532 "; it must list every one of them, exactly once")
533 }
534 order := make([]*folder, 0, len(parts))
535 for _, p := range parts {
536 id, err := strconv.ParseUint(strings.TrimSpace(p), 10, 64)
537 if err != nil {
538 panic("kourtv3: an order is a comma-separated list of folder ids")
539 }
540 var f *folder
541 for _, s := range sibs {
542 if s.id == id {
543 f = s
544 break
545 }
546 }
547 if f == nil {
548 panic("kourtv3: folder " + strconv.FormatUint(id, 10) +
549 " is not under that heading")
550 }
551 for _, x := range order {
552 if x.id == id {
553 panic("kourtv3: folder " + strconv.FormatUint(id, 10) +
554 " is listed twice")
555 }
556 }
557 order = append(order, f)
558 }
559 for i, f := range order {
560 f.ord = i + 1
561 }
562 emitModAct(c.id, 0, "folder-sort", who)
563}
564func FolderTree(courtSlug string) string {
565 c := mustCourt(courtSlug)
566 if c.mod == nil {
567 return ""
568 }
569 var b strings.Builder
570 n := 0
571 for _, f := range c.mod.folderRows() {
572 if n > 0 {
573 b.WriteString(",")
574 }
575 flags := ""
576 if f.retired {
577 flags += "r"
578 }
579 if f.purged {
580 flags += "p"
581 }
582 if f.focusByDefault {
583 flags += "f"
584 }
585 if f.image != nil {
586 flags += "i"
587 }
588 if flags == "" {
589 flags = "-"
590 }
591 b.WriteString(strconv.FormatUint(f.id, 10) + ":" +
592 strconv.FormatUint(f.parent, 10) + ":" + flags + ":" +
593 strconv.FormatUint(f.bornOf, 10))
594 n++
595 }
596 return b.String()
597}
598func FolderRetired(courtSlug string, folderID uint64) bool {
599 c := mustCourt(courtSlug)
600 if c.mod == nil {
601 return false
602 }
603 v := c.mod.folders.Get(beClaimKey(folderID))
604 if v == nil {
605 return false
606 }
607 return v.(*folder).retired
608}
609func FolderParent(courtSlug string, folderID uint64) uint64 {
610 c := mustCourt(courtSlug)
611 if c.mod == nil {
612 return 0
613 }
614 v := c.mod.folders.Get(beClaimKey(folderID))
615 if v == nil {
616 return 0
617 }
618 return v.(*folder).parent
619}
620func FolderPurged(courtSlug string, folderID uint64) bool {
621 c := mustCourt(courtSlug)
622 if c.mod == nil {
623 panic("kourtv3: no such folder")
624 }
625 return c.mod.mustFolder(folderID).purged
626}