Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}