blog.gno
9.94 Kb · 397 lines
1package blog
2
3import (
4 "chain/runtime"
5 "strings"
6 "time"
7
8 "gno.land/p/moul/md"
9 "gno.land/p/nt/avl/v0"
10 "gno.land/p/nt/ownable/v0"
11 "gno.land/p/nt/ownable/v0/exts/authorizable"
12 "gno.land/p/nt/seqid/v0"
13 "gno.land/p/nt/ufmt/v0"
14)
15
16type Blog struct {
17 Authorizable *authorizable.Authorizable
18 title string
19 prefix string
20 PostId seqid.ID
21 Posts *avl.Tree // id --> *Post
22 PostsBySlug *avl.Tree // slug --> *Post
23 PostsByUpdatedAt *avl.Tree // "<id>::updatedAt" --> *Post (To ensure no overlay)
24 TagsIndex *avl.Tree // tagName --> int
25 TagsSorted *avl.Tree // "<count>::tag" --> tag (To sort from most/least common tags)
26 AuthorsIndex *avl.Tree // authorAddress --> int
27 AuthorsSorted *avl.Tree // "<count>::author" --> author (To sort from most/least common authors)
28
29 CustomHeader *[]HeaderPreset // header.gno
30 DisableLikes bool
31 DisableComments bool
32 UserResolver UserResolver // moderation.gno
33}
34
35func (b Blog) Title() string {
36 return b.title
37}
38
39func (b Blog) Prefix() string {
40 return b.prefix
41}
42
43func NewBlog(title string, owner address, opts ...Options) (*Blog, error) {
44 if title == "" {
45 return nil, ErrEmptyTitle
46 }
47 if err := CheckAddr(owner); err != nil {
48 return nil, err
49 }
50
51 pkgPath := runtime.CurrentRealm().PkgPath()
52 if pkgPath == "" {
53 return nil, ErrEmptyPrefix
54 }
55 prefix := strings.Split(pkgPath, "gno.land")[1]
56 if prefix == "" {
57 return nil, ErrEmptyPrefix
58 }
59
60 auth := authorizable.New(ownable.NewWithAddress(owner))
61 blog := &Blog{
62 Authorizable: auth,
63 title: title,
64 prefix: prefix,
65 PostId: seqid.ID(0),
66 Posts: avl.NewTree(),
67 PostsBySlug: avl.NewTree(),
68 PostsByUpdatedAt: avl.NewTree(),
69 TagsIndex: avl.NewTree(),
70 TagsSorted: avl.NewTree(),
71 AuthorsIndex: avl.NewTree(),
72 AuthorsSorted: avl.NewTree(),
73 DisableLikes: false,
74 DisableComments: false,
75 UserResolver: nil,
76 CustomHeader: nil,
77 }
78
79 for _, opt := range opts {
80 opt(blog)
81 }
82 return blog, nil
83}
84
85type Options func(*Blog)
86
87func WithDisableLikes() Options {
88 return func(b *Blog) {
89 b.DisableLikes = true
90 b.SetDisablePostLikes(true)
91 }
92}
93
94func WithDisableComments() Options {
95 return func(b *Blog) {
96 b.DisableComments = true
97 b.SetDisableComments(true)
98 }
99}
100
101func WithUserResolver(resolver UserResolver) Options {
102 return func(b *Blog) {
103 b.UserResolver = resolver
104 }
105}
106
107func (b *Blog) AddPost(post *Post) error {
108 b.Authorizable.AssertPreviousOnAuthList()
109
110 post.id = b.PostId.Next()
111 if _, err := b.GetPostBySlug(post.Slug()); err == nil {
112 return ErrPostAlreadyExists
113 }
114 if err := CheckAddr(address(post.Publisher())); err != nil {
115 return err
116 }
117
118 post.DisableLikes = b.DisableLikes
119 post.DisableComments = b.DisableComments
120 if b.UserResolver != nil {
121 post.UserResolver = b.UserResolver
122 post.authors = post.resolveAuthors(post.Authors())
123 post.publisher, _ = CheckUser(post.Publisher(), b.UserResolver)
124 }
125
126 b.addToIndex(post)
127 b.Posts.Set(post.ID(), post)
128 b.PostsBySlug.Set(post.Slug(), post)
129 b.PostsByUpdatedAt.Set(post.ID()+"::"+post.UpdatedAt().String(), post)
130 return nil
131}
132
133func (b *Blog) UpdatePostById(id string, newPost *Post) error {
134 b.Authorizable.AssertPreviousOnAuthList()
135
136 post, err := b.GetPostById(id)
137 if err != nil {
138 return err
139 }
140 postBySlug, _ := b.PostsBySlug.Get(post.Slug())
141 postByUpdated, _ := b.PostsByUpdatedAt.Get(post.ID() + "::" + post.UpdatedAt().String())
142 post.UpdatePost(newPost)
143 postBySlug.(*Post).UpdatePost(newPost)
144 postByUpdated.(*Post).UpdatePost(newPost)
145 return nil
146}
147
148func (b *Blog) UpdatePostBySlug(slug string, newPost *Post) error {
149 b.Authorizable.AssertPreviousOnAuthList()
150
151 post, err := b.GetPostBySlug(slug)
152 if err != nil {
153 return err
154 }
155 postById, _ := b.Posts.Get(post.ID())
156 postByUpdated, _ := b.PostsByUpdatedAt.Get(post.ID() + "::" + post.UpdatedAt().String())
157 post.UpdatePost(newPost)
158 postById.(*Post).UpdatePost(newPost)
159 postByUpdated.(*Post).UpdatePost(newPost)
160 return nil
161}
162
163func (b *Blog) DeletePostById(id string) error {
164 b.Authorizable.AssertPreviousOnAuthList()
165
166 post, err := b.GetPostById(id)
167 if err != nil {
168 return err
169 }
170 return b.DeletePost(post)
171}
172
173func (b *Blog) DeletePostBySlug(slug string) error {
174 b.Authorizable.AssertPreviousOnAuthList()
175
176 post, err := b.GetPostBySlug(slug)
177 if err != nil {
178 return err
179 }
180 return b.DeletePost(post)
181}
182
183func (b *Blog) DeletePost(post *Post) error {
184 _, removed := b.Posts.Remove(post.ID())
185 _, removedSlug := b.PostsBySlug.Remove(post.Slug())
186 _, removedUpdated := b.PostsByUpdatedAt.Remove(post.ID() + "::" + post.UpdatedAt().String())
187 if !removed || !removedSlug || !removedUpdated {
188 return ErrDeleteFailed
189 }
190 return nil
191}
192
193func (b *Blog) LikePostById(id string) error { // toggles between like and unlike
194 b.Authorizable.AssertPreviousOnAuthList()
195
196 post, err := b.GetPostById(id)
197 if err != nil {
198 return err
199 }
200 if err := post.LikePost(runtime.PreviousRealm().Address().String()); err != nil {
201 post.UnlikePost(runtime.PreviousRealm().Address().String())
202 }
203 return nil
204
205}
206
207func (b *Blog) LikePostBySlug(slug string) error { // toggles between like and unlike
208 b.Authorizable.AssertPreviousOnAuthList()
209
210 post, err := b.GetPostBySlug(slug)
211 if err != nil {
212 return err
213 }
214 if err := post.LikePost(runtime.PreviousRealm().Address().String()); err != nil {
215 post.UnlikePost(runtime.PreviousRealm().Address().String())
216 }
217 return nil
218}
219
220func (b Blog) GetPostById(id string) (*Post, error) {
221 post, found := b.Posts.Get(id)
222 if !found {
223 return nil, ErrPostNotFound
224 }
225 return post.(*Post), nil
226}
227
228func (b Blog) GetPostBySlug(slug string) (*Post, error) {
229 post, found := b.PostsBySlug.Get(slug)
230 if !found {
231 return nil, ErrPostNotFound
232 }
233 return post.(*Post), nil
234}
235
236func (b *Blog) SetDisablePostLikes(disable bool) {
237 b.Authorizable.AssertPreviousOnAuthList()
238
239 b.DisableLikes = disable
240 b.Posts.Iterate("", "", func(_ string, value any) bool {
241 post := value.(*Post)
242 post.DisableLikes = disable
243 return false
244 })
245 b.PostsBySlug.Iterate("", "", func(_ string, value any) bool {
246 post := value.(*Post)
247 post.DisableLikes = disable
248 return false
249 })
250 b.PostsByUpdatedAt.Iterate("", "", func(_ string, value any) bool {
251 post := value.(*Post)
252 post.DisableLikes = disable
253 return false
254 })
255}
256
257func (b *Blog) SetDisableComments(disable bool) {
258 b.Authorizable.AssertPreviousOnAuthList()
259
260 b.DisableComments = disable
261 b.Posts.Iterate("", "", func(_ string, value any) bool {
262 post := value.(*Post)
263 post.DisableComments = disable
264 return false
265 })
266 b.PostsBySlug.Iterate("", "", func(_ string, value any) bool {
267 post := value.(*Post)
268 post.DisableComments = disable
269 return false
270 })
271 b.PostsByUpdatedAt.Iterate("", "", func(_ string, value any) bool {
272 post := value.(*Post)
273 post.DisableComments = disable
274 return false
275 })
276}
277
278func (b *Blog) SetUserResolver(resolver UserResolver) {
279 b.Authorizable.AssertPreviousOnAuthList()
280 b.UserResolver = resolver
281}
282
283func (b *Blog) SetCustomHeader(presets []HeaderPreset) {
284 b.Authorizable.AssertPreviousOnAuthList()
285 b.CustomHeader = &presets
286}
287
288func (b Blog) Mention(role, recipient string) string {
289 if role == "author" {
290 return md.Bold(md.Link("@"+recipient, b.Prefix()+":authors/"+recipient))
291 }
292 if role == "commenter" {
293 return md.Bold(md.Link("@"+recipient, b.Prefix()+":commenters/"+recipient))
294 }
295 if role == "tag" {
296 return md.Bold(md.Link("#"+recipient, b.Prefix()+":tags/"+recipient))
297 }
298 return ""
299}
300
301func (b *Blog) addToIndex(post *Post) {
302 for _, tag := range post.Tags() {
303 oldCount := 0
304 if val, found := b.TagsIndex.Get(tag); found {
305 oldCount = val.(int)
306 b.TagsSorted.Remove(ufmt.Sprintf("%05d::%s", oldCount, tag))
307 }
308 newCount := oldCount + 1
309 b.TagsIndex.Set(tag, newCount)
310 b.TagsSorted.Set(ufmt.Sprintf("%05d::%s", newCount, tag), newCount)
311 }
312 for _, author := range post.Authors() {
313 oldCount := 0
314 if val, found := b.AuthorsIndex.Get(author); found {
315 oldCount = val.(int)
316 b.AuthorsSorted.Remove(ufmt.Sprintf("%05d::%s", oldCount, author))
317 }
318 newCount := oldCount + 1
319 b.AuthorsIndex.Set(author, newCount)
320 b.AuthorsSorted.Set(ufmt.Sprintf("%05d::%s", newCount, author), newCount)
321 }
322}
323
324func (b Blog) filterPostsStartEnd(tree *avl.Tree, start, end *time.Time) *avl.Tree {
325 filtered := avl.NewTree()
326 tree.Iterate("", "", func(k string, v interface{}) bool {
327 post := v.(*Post)
328 if (start == nil || post.CreatedAt().After(*start)) &&
329 (end == nil || post.CreatedAt().Before(*end)) {
330 filtered.Set(k, post)
331 }
332 return false
333 })
334 return filtered
335}
336
337func (b Blog) filterPostsByField(field, value, sort string) (*avl.Tree, bool) {
338 recentPosts := avl.NewTree()
339 alphaPosts := avl.NewTree()
340 updatedPosts := avl.NewTree()
341
342 switch field {
343 case "tag", "author":
344 b.Posts.ReverseIterate("", "", func(k string, v interface{}) bool {
345 post := v.(*Post)
346 var match bool
347 if field == "tag" {
348 match = hasField(post.Tags(), value)
349 } else {
350 match = hasField(post.Authors(), value)
351 }
352 if match {
353 recentPosts.Set(k, post)
354 alphaPosts.Set(post.Slug(), post)
355 updatedPosts.Set(post.UpdatedAt().String(), post)
356 }
357 return false
358 })
359
360 case "commenter":
361 commenterId := seqid.ID(0)
362 b.Posts.ReverseIterate("", "", func(k string, v interface{}) bool {
363 post := v.(*Post)
364 comments := post.GetCommentsByAuthor(value)
365 for _, comment := range comments {
366 keyPrefix := commenterId.Next().String() + "::"
367 recentPosts.Set(keyPrefix+post.ID(), comment)
368 alphaPosts.Set(keyPrefix+post.Slug(), comment)
369 updatedPosts.Set(keyPrefix+post.UpdatedAt().String(), comment)
370 }
371 return false
372 })
373 }
374
375 switch sort {
376 case "alpha":
377 return alphaPosts, alphaPosts.Size() > 0
378 case "update":
379 return updatedPosts, updatedPosts.Size() > 0
380 default:
381 return recentPosts, recentPosts.Size() > 0
382 }
383}
384
385func (b Blog) findPostBySlug(value string) (string, bool) {
386 var foundKey string
387 var found bool
388 b.Posts.Iterate("", "", func(k string, v interface{}) bool {
389 post := v.(*Post)
390 if post.Slug() == value {
391 foundKey = k
392 found = true
393 }
394 return found
395 })
396 return foundKey, found
397}