Search Apps Documentation Source Content File Folder Download Copy Actions Download

post.gno

8.87 Kb · 385 lines
  1package blog
  2
  3import (
  4	"strconv"
  5	"strings"
  6	"time"
  7
  8	"gno.land/p/moul/md"
  9	"gno.land/p/nt/avl"
 10	"gno.land/p/nt/seqid"
 11	"gno.land/p/nt/ufmt"
 12)
 13
 14type Post struct {
 15	id           seqid.ID
 16	slug         string
 17	title        string
 18	body         string
 19	createdAt    time.Time
 20	updatedAt    time.Time
 21	publishedAt  time.Time
 22	tags         []string
 23	authors      []string
 24	publisher    string
 25	likes        *avl.Tree // addr --> bool
 26	CommentsId   seqid.ID
 27	Comments     *avl.Tree // id --> *Comment
 28	CommentsSize int
 29
 30	PreviewFooter   string // additional text, e.g. "via @lou" or txlink calls
 31	DisableLikes    bool
 32	DisableComments bool
 33	UserResolver    UserResolver
 34}
 35
 36func (p Post) ID() string {
 37	return p.id.String()
 38}
 39
 40func (p Post) Slug() string {
 41	return p.slug
 42}
 43
 44func (p Post) Title() string {
 45	return p.title
 46}
 47
 48func (p Post) Body() string {
 49	return p.body
 50}
 51
 52func (p Post) CreatedAt() time.Time {
 53	return p.createdAt
 54}
 55
 56func (p Post) UpdatedAt() time.Time {
 57	return p.updatedAt
 58}
 59
 60func (p Post) PublishedAt() time.Time {
 61	return p.publishedAt
 62}
 63
 64func (p Post) Tags() []string {
 65	return p.tags
 66}
 67
 68func (p Post) Authors() []string {
 69	return p.authors
 70}
 71
 72func (p Post) Publisher() string {
 73	return p.publisher
 74}
 75
 76func (p Post) Likes() int {
 77	return p.likes.Size()
 78}
 79
 80func NewPost(slug, title, body, publicationDate, caller string, authors, tags []string) (*Post, error) {
 81	newSlug := handleSlug(slug, title)
 82	if title == "" {
 83		return nil, ErrEmptyTitle
 84	}
 85	if body == "" {
 86		return nil, ErrEmptyBody
 87	}
 88
 89	var err error
 90	publishedAt := time.Now()
 91	if publicationDate != "" {
 92		publishedAt, err = time.Parse(time.RFC3339, publicationDate)
 93		if err != nil {
 94			return nil, err
 95		}
 96	}
 97
 98	return &Post{
 99		slug:            newSlug,
100		title:           title,
101		body:            body,
102		createdAt:       publishedAt,
103		updatedAt:       publishedAt,
104		publishedAt:     time.Now(),
105		tags:            tags,
106		authors:         authors,
107		publisher:       caller,
108		likes:           avl.NewTree(),
109		CommentsId:      seqid.ID(0),
110		Comments:        avl.NewTree(),
111		CommentsSize:    0,
112		PreviewFooter:   "",
113		DisableLikes:    false,
114		DisableComments: false,
115		UserResolver:    nil,
116	}, nil
117}
118
119func (p *Post) UpdatePost(newPost *Post) {
120	if p.UserResolver != nil {
121		p.authors = p.resolveAuthors(p.Authors())
122		p.publisher, _ = CheckUser(p.Publisher(), p.UserResolver)
123	} else {
124		p.authors = newPost.authors
125	}
126
127	p.slug = newPost.slug
128	p.title = newPost.title
129	p.body = newPost.body
130	p.tags = newPost.tags
131	p.CommentsId = newPost.CommentsId
132	p.Comments = newPost.Comments
133	p.likes = newPost.likes
134	p.DisableLikes = newPost.DisableLikes
135	p.DisableComments = newPost.DisableComments
136	p.PreviewFooter = newPost.PreviewFooter
137	p.updatedAt = time.Now()
138}
139
140func (p *Post) AddComment(comment *Comment) error {
141	if p == nil {
142		return ErrInvalidPost
143	}
144	user, err := CheckUser(comment.Author(), p.UserResolver)
145	if err != nil {
146		return err
147	}
148	comment.author = user
149
150	p.CommentsId.Next()
151	p.CommentsSize++
152	comment.SetID(p.CommentsId)
153	p.Comments.Set(p.CommentsId.String(), comment)
154	return nil
155}
156
157func (p *Post) AddReply(parentID string, reply *Comment) error {
158	if p == nil {
159		return ErrInvalidPost
160	}
161	user, err := CheckUser(reply.Author(), p.UserResolver)
162	if err != nil {
163		return err
164	}
165	reply.author = user
166
167	parent, err := p.GetCommentByID(parentID)
168	if err != nil {
169		return err
170	}
171
172	p.CommentsId.Next()
173	p.CommentsSize++
174	reply.SetID(p.CommentsId)
175	parent.Replies.Set(p.CommentsId.String(), reply)
176	return nil
177}
178
179func (p *Post) EditCommentByID(id string, content string) error {
180	comment, err := p.GetCommentByID(id)
181	if err != nil {
182		return err
183	}
184	if err := comment.Edit(content); err != nil {
185		return err
186	}
187	return nil
188}
189
190func (p *Post) DeleteCommentById(id string) error {
191	if _, err := p.GetCommentByID(id); err != nil {
192		return err
193	}
194	_, removed := p.Comments.Remove(id)
195	if !removed {
196		return ErrCommentDeleteFailed
197	}
198	p.CommentsSize--
199	return nil
200}
201
202func (p *Post) PinCommentById(id string) error {
203	comment, err := p.GetCommentByID(id)
204	if err != nil {
205		return ErrCommentNotFound
206	}
207	comment.Pin()
208	return nil
209}
210
211func (p *Post) LikePost(addr string) error {
212	if p.DisableLikes {
213		return ErrLikesDisabled
214	}
215	if _, exists := p.likes.Get(addr); exists {
216		return ErrAlreadyLiked
217	}
218	p.likes.Set(addr, true)
219	return nil
220}
221
222func (p *Post) UnlikePost(addr string) error {
223	if p.DisableLikes {
224		return ErrLikesDisabled
225	}
226	if _, exists := p.likes.Get(addr); !exists {
227		return ErrNotLiked
228	}
229	if _, removed := p.likes.Remove(addr); !removed {
230		return ErrUnlikeFailed
231	}
232	return nil
233}
234
235func (p *Post) SetPreviewFooter(footer string) {
236	p.PreviewFooter = footer
237}
238
239func (p Post) GetCommentByID(id string) (*Comment, error) {
240	comment, found := p.Comments.Get(id)
241	if found {
242		return comment.(*Comment), nil
243	}
244
245	var result *Comment
246	p.Comments.ReverseIterate("", "", func(_ string, v interface{}) bool {
247		c := v.(*Comment)
248		if found := searchInReplies(c.Replies, id, &result); found {
249			return true
250		}
251		return false
252	})
253	if result == nil {
254		return nil, ErrCommentNotFound
255	}
256	return result, nil
257}
258
259func (p Post) GetCommentsByAuthor(author string) []*Comment {
260	var comments []*Comment = nil
261	p.Comments.ReverseIterate("", "", func(_ string, comment interface{}) bool {
262		c := comment.(*Comment)
263		if c.Author() == author {
264			comments = append(comments, c)
265		}
266		comments = append(comments, p.getReplies(c.Replies, author)...)
267		return false
268	})
269	return comments
270}
271
272func (p Post) RenderPreview(gridMode bool, timeFmt, prefix string) string {
273	out := md.H2(md.Link(p.Title(), prefix+":posts/"+p.Slug())) + "\n\n"
274	out += md.H5(md.Italic("/"+p.Slug())) + "\n\n"
275	if p.UpdatedAt() != p.CreatedAt() {
276		out += formatTime(p.UpdatedAt(), timeFmt) + " (updated)\n\n"
277	} else {
278		out += formatTime(p.CreatedAt(), timeFmt) + "\n\n"
279	}
280	if !gridMode {
281		out += md.Bold("author(s):") + " " + strings.Join(p.Authors(), ", ") + "\n\n"
282	}
283	if len(p.Tags()) > 0 && p.Tags()[0] != "" {
284		out += md.Bold("tags:") + " `" + strings.Join(p.Tags(), "`, `") + "`\n\n"
285	}
286	if !p.DisableComments {
287		out += md.Bold(md.Link("comments ("+strconv.Itoa(p.CommentsSize), prefix+":posts/"+p.Slug()+"#comments")) + ") "
288	}
289	if !p.DisableLikes {
290		out += ufmt.Sprintf("| ❤️ (%d)\n\n", p.Likes())
291	}
292	if p.PreviewFooter != "" {
293		out += p.PreviewFooter + "\n\n"
294	}
295	out += md.HorizontalRule()
296	return out
297}
298
299func (p Post) RenderPost(prefix string) string {
300	resolvedAuthors := p.resolveAuthors(p.Authors())
301	out := md.H1(p.Title()) + "\n\n"
302	if p.Comments.Size() > 0 {
303		out += md.Link(strconv.Itoa(p.CommentsSize)+" Comment(s)", "#comments") + "\n\n"
304	}
305	out += md.Italic("Author(s):") + " " + renderAuthorLinks(prefix, resolvedAuthors) + "\n\n"
306	out += p.Body() + "\n\n"
307	out += md.HorizontalRule()
308	out += md.Bold("Created on:") + " " + formatTime(p.CreatedAt(), "full") + "\n\n"
309	out += md.Bold("Published on:") + " " + formatTime(p.PublishedAt(), "full") + "\n\n"
310	if p.UpdatedAt() != p.CreatedAt() {
311		out += md.Bold("Last updated:") + " " + formatTime(p.UpdatedAt(), "full") + "\n\n"
312	}
313	out += md.Bold("Publisher:") + " " + md.Link(p.Publisher(), "/u/"+p.Publisher()) + "\n\n"
314	out += md.Bold("Tags:") + " " + renderTagLinks(prefix, p.Tags()) + "\n\n"
315	if !p.DisableLikes {
316		out += ufmt.Sprintf("❤️ %d\n\n", p.Likes())
317	}
318	if !p.DisableComments {
319		out += md.HorizontalRule()
320		out += p.RenderComments(prefix)
321	}
322	return out
323}
324
325func (p Post) RenderComments(prefix string) string {
326	out := md.H3("Comments ("+strconv.Itoa(p.CommentsSize)+")") + "\n\n"
327	if p.Comments.Size() == 0 {
328		out += "No comments yet.\n\n"
329		return out
330	}
331	p.Comments.ReverseIterate("", "", func(_ string, v interface{}) bool {
332		comment := v.(*Comment)
333		if comment.Pinned() {
334			out += comment.Render(prefix, 0, p.UserResolver) + "\n\n"
335		}
336		return false
337	})
338	p.Comments.ReverseIterate("", "", func(_ string, v interface{}) bool {
339		comment := v.(*Comment)
340		if !comment.Pinned() {
341			out += comment.Render(prefix, 0, p.UserResolver) + "\n\n"
342		}
343		return false
344	})
345	return out
346}
347
348func (p Post) getReplies(replies *avl.Tree, author string) []*Comment {
349	var comments []*Comment
350	if replies == nil {
351		return comments
352	}
353	replies.ReverseIterate("", "", func(_ string, value any) bool {
354		comment := value.(*Comment)
355		if comment.Author() == author {
356			comments = append(comments, comment)
357		}
358		if comment.Replies.Size() > 0 {
359			comments = append(comments, p.getReplies(comment.Replies, author)...)
360		}
361		return false
362	})
363	return comments
364}
365
366func (p Post) resolveAuthors(authors []string) []string {
367	var resolvedAuthors []string
368	for _, author := range authors {
369		user, err := CheckUser(author, p.UserResolver)
370		if err == nil {
371			resolvedAuthors = append(resolvedAuthors, user)
372		} else {
373			resolvedAuthors = append(resolvedAuthors, author)
374		}
375	}
376	return resolvedAuthors
377}
378
379func renderTagLinks(prefix string, tags []string) string {
380	return renderLinks(prefix, "tags", tags, "#")
381}
382
383func renderAuthorLinks(prefix string, authors []string) string {
384	return renderLinks(prefix, "authors", authors, "")
385}