Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}