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