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}