package models import ( "errors" "fmt" "html/template" "math" "regexp" "strconv" "strings" "time" "git.kirsle.net/apps/gophertype/pkg/console" "git.kirsle.net/apps/gophertype/pkg/markdown" "github.com/albrow/forms" "github.com/jinzhu/gorm" ) type postMan struct{} // Posts is a singleton manager class for Post model access. var Posts = postMan{} // Post represents a single blog entry. type Post struct { gorm.Model Title string Fragment string `gorm:"unique_index"` ContentType string `gorm:"default:html"` AuthorID uint // foreign key to User.ID Thumbnail string // image thumbnail for the post Body string Privacy string Sticky bool EnableComments bool Tags []TaggedPost Author User `gorm:"foreign_key:UserID"` // Private fields not in DB. CommentCount int `gorm:"-"` } // PagedPosts holds a paginated response of multiple posts. type PagedPosts struct { Posts []Post Page int PerPage int Pages int Total int NextPage int PreviousPage int } // Regexp for matching a thumbnail image for a blog post. var ThumbnailImageRegexp = regexp.MustCompile(`['"(]([a-zA-Z0-9-_:/?.=&]+\.(?:jpe?g|png|gif))['")]`) // New creates a new Post model. func (m postMan) New() Post { return Post{ ContentType: Markdown, Privacy: Public, EnableComments: true, } } // Load a post by ID. func (m postMan) Load(id int) (Post, error) { var post Post r := DB.Preload("Author").Preload("Tags").First(&post, id) return post, r.Error } // LoadFragment loads a blog post by its URL fragment. func (m postMan) LoadFragment(fragment string) (Post, error) { var post Post r := DB.Preload("Author").Preload("Tags").Where("fragment = ?", strings.Trim(fragment, "/")).First(&post) return post, r.Error } // GetIndex returns the index page of blog posts. func (m postMan) GetIndexPosts(privacy string, page, perPage int) (PagedPosts, error) { var pp = PagedPosts{ Page: page, PerPage: perPage, } if pp.Page < 1 { pp.Page = 1 } if pp.PerPage <= 0 { pp.PerPage = 20 } query := DB.Debug().Preload("Author").Preload("Tags"). Where("privacy = ?", privacy). Order("sticky desc, created_at desc") // Count the total number of rows for paging purposes. query.Model(&Post{}).Count(&pp.Total) // Query the paginated slice of results. r := query. Offset((pp.Page - 1) * pp.PerPage). Limit(pp.PerPage). Find(&pp.Posts) pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage))) if pp.Page < pp.Pages { pp.NextPage = pp.Page + 1 } if pp.Page > 1 { pp.PreviousPage = pp.Page - 1 } if err := pp.CountComments(); err != nil { console.Error("PagedPosts.CountComments: %s", err) } return pp, r.Error } // GetPostsByTag gets posts by a certain tag. func (m postMan) GetPostsByTag(tag, privacy string, page, perPage int) (PagedPosts, error) { var pp = PagedPosts{ Page: page, PerPage: perPage, } if pp.Page < 1 { pp.Page = 1 } if pp.PerPage <= 0 { pp.PerPage = 20 } // Get the distinct post IDs for this tag. var tags []TaggedPost var postIDs []uint r := DB.Where("tag = ?", tag).Find(&tags) for _, taggedPost := range tags { postIDs = append(postIDs, taggedPost.PostID) } if len(postIDs) == 0 { return pp, errors.New("no posts found") } // Query this set of posts. query := DB.Debug().Preload("Author").Preload("Tags"). Where("id IN (?) AND privacy = ?", postIDs, privacy). Order("sticky desc, created_at desc") // Count the total number of rows for paging purposes. query.Model(&Post{}).Count(&pp.Total) // Query the paginated slice of results. r = query. Offset((page - 1) * perPage). Limit(perPage). Find(&pp.Posts) pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage))) if pp.Page < pp.Pages { pp.NextPage = pp.Page + 1 } if pp.Page > 1 { pp.PreviousPage = pp.Page - 1 } if err := pp.CountComments(); err != nil { console.Error("PagedPosts.CountComments: %s", err) } return pp, r.Error } // CountComments gets comment counts for one or more posts. // Returns a map[uint]int mapping post ID to comment count. func (m postMan) CountComments(posts ...Post) (map[uint]int, error) { var result = map[uint]int{} // Create the comment thread IDs. var threadIDs = make([]string, len(posts)) for i, post := range posts { threadIDs[i] = fmt.Sprintf("post-%d", post.ID) } // Query comment counts for each thread. if len(threadIDs) > 0 { rows, err := DB.Table("comments"). Select("thread, count(*) as count"). Group("thread"). Rows() if err != nil { return result, err } for rows.Next() { var thread string var count int if err := rows.Scan(&thread, &count); err != nil { console.Error("CountComments: rows.Scan: %s", err) } postID, err := strconv.Atoi(strings.TrimPrefix(thread, "post-")) if err != nil { console.Warn("CountComments: strconv.Atoi(%s): %s", thread, err) } result[uint(postID)] = count } } return result, nil } // CountComments on the posts in a PagedPosts list. func (pp *PagedPosts) CountComments() error { counts, err := Posts.CountComments(pp.Posts...) if err != nil { return err } console.Info("counts: %+v", counts) for i, post := range pp.Posts { if count, ok := counts[post.ID]; ok { pp.Posts[i].CommentCount = count } } return nil } // PreviewHTML returns the post's body as rendered HTML code, but only above // the tag for index views. func (p Post) PreviewHTML() template.HTML { var ( parts = strings.Split(p.Body, "") hasMore = len(parts) > 1 body = strings.TrimSpace(parts[0]) ) if p.ContentType == Markdown { if hasMore { body += fmt.Sprintf("\n\n[Read more...](/%s)", p.Fragment) } return template.HTML(markdown.RenderTrustedMarkdown(body)) } body += fmt.Sprintf(`

Read more...

`, p.Fragment) return template.HTML(body) } // HTML returns the post's body as rendered HTML code. func (p Post) HTML() template.HTML { body := strings.ReplaceAll(p.Body, "", "") if p.ContentType == Markdown { return template.HTML(markdown.RenderTrustedMarkdown(body)) } return template.HTML(body) } // Save a post. // This method also makes sure a unique Fragment is set and links the Tags correctly. func (p *Post) Save() error { // Generate the default fragment from the post title. if p.Fragment == "" { fragment := strings.ToLower(p.Title) fragment = regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(fragment, "-") fragment = strings.ReplaceAll(fragment, "--", "-") console.Error("frag: %s", fragment) p.Fragment = strings.Trim(fragment, "-") // If still no fragment, make one up from the current time. if p.Fragment == "" { p.Fragment = time.Now().Format("2006-01-02-150405") } } // Ensure the fragment is unique! { if exist, err := Posts.LoadFragment(p.Fragment); err != nil && exist.ID != p.ID { console.Debug("Post.Save: fragment %s is not unique, trying to resolve", p.Fragment) var resolved bool for i := 2; i <= 100; i++ { fragment := fmt.Sprintf("%s-%d", p.Fragment, i) console.Debug("Post.Save: try fragment '%s'", fragment) _, err = Posts.LoadFragment(fragment) if err == nil { continue } p.Fragment = fragment resolved = true break } if !resolved { return fmt.Errorf("failed to generate a unique URL fragment for '%s' after 100 attempts", p.Fragment) } } } // Cache the post thumbnail from the body. if thumbnail, ok := p.ExtractThumbnail(); ok { p.Thumbnail = thumbnail } // Empty tags list. if len(p.Tags) == 1 && p.Tags[0].Tag == "" { p.Tags = []TaggedPost{} } // TODO: tag relationships. For now just delete and re-add them all. if p.ID != 0 { DB.Where("post_id = ?", p.ID).Delete(TaggedPost{}) } // Dedupe tags. p.fixTags() // Save the post. if DB.NewRecord(p) { return DB.Create(&p).Error } return DB.Save(&p).Error } // ParseForm populates a Post from an HTTP form. func (p *Post) ParseForm(form *forms.Data) { p.Title = form.Get("title") p.Fragment = form.Get("fragment") p.ContentType = form.Get("content-type") p.Body = form.Get("body") p.Privacy = form.Get("privacy") p.Sticky = form.GetBool("sticky") p.EnableComments = form.GetBool("enable-comments") // Parse the tags array. This replaces the post.Tags with an empty TaggedPost // list containing only the string Tag values. The IDs and DB side will be // patched up when the post gets saved. p.Tags = []TaggedPost{} tags := strings.Split(form.Get("tags"), ",") for _, tag := range tags { tag = strings.TrimSpace(tag) if len(tag) == 0 { continue } p.Tags = append(p.Tags, TaggedPost{ Tag: tag, }) } } // ExtractThumbnail searches and returns a thumbnail image to represent the post. // It will be the first image embedded in the post body, or nothing. func (p Post) ExtractThumbnail() (string, bool) { result := ThumbnailImageRegexp.FindStringSubmatch(p.Body) if len(result) < 2 { return "", false } return result[1], true } // TagsString turns the post tags into a comma separated string. func (p Post) TagsString() string { console.Error("TagsString: %+v", p.Tags) var tags = make([]string, len(p.Tags)) for i, tag := range p.Tags { tags[i] = tag.Tag } return strings.Join(tags, ", ") } // fixTags is a pre-Save function to fix up the Tags relationships. // It checks that each tag has an ID, and if it doesn't have an ID yet, removes // it if a duplicate tag does exist that has an ID. func (p *Post) fixTags() { // De-duplicate tag values. var dedupe = map[string]interface{}{} var finalTags []TaggedPost for _, tag := range p.Tags { if _, ok := dedupe[tag.Tag]; !ok { finalTags = append(finalTags, tag) dedupe[tag.Tag] = nil } } p.Tags = finalTags }