gophertype/pkg/models/posts.go

547 regels
13 KiB
Go

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"
"git.kirsle.net/apps/gophertype/pkg/mogrify"
"git.kirsle.net/apps/gophertype/pkg/rng"
"github.com/albrow/forms"
)
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 {
BaseModel
Title string
Fragment string `gorm:"unique_index"`
ContentType string `gorm:"default:'html'"`
AuthorID int // 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
}
// PostArchive holds the posts for a single year/month for the archive page.
type PostArchive struct {
Label string
Date time.Time
Posts []Post
}
// 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
}
// LoadRandom gets a random post for a given privacy setting.
func (m postMan) LoadRandom(privacy string) (Post, error) {
// Find all the post IDs.
var pp []Post
r := DB.Select("id").Where("privacy = ?", privacy).Find(&pp)
if r.Error != nil || len(pp) == 0 {
return Post{}, r.Error
}
// Pick one at random.
randPost := pp[rng.Intn(len(pp))]
post, err := Posts.Load(randPost.ID)
return post, err
}
// 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.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
}
// Multi-tag query.
whitelist, blacklist, err := ParseMultitag(tag)
if err != nil {
return pp, err
}
// Query the whitelist of post IDs which match the whitelist tags.
postIDs := getPostIDsByTag("tag IN (?)", whitelist)
notPostIDs := getPostIDsByTag("tag IN (?)", blacklist)
postIDs = narrowWhitelistByBlacklist(postIDs, notPostIDs)
if len(postIDs) == 0 {
return pp, errors.New("no posts found")
}
// Query this set of posts.
query := DB.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
}
// getPostIDsByTag helper function returns the post IDs that either whitelist,
// or blacklist, a set of tags.
func getPostIDsByTag(query string, value []string) []int {
var tags []TaggedPost
var result []int
if len(value) == 0 {
return result
}
DB.Where(query, value).Find(&tags)
for _, tag := range tags {
result = append(result, tag.PostID)
}
return result
}
// narrowWhitelistByBlacklist removes IDs in whitelist that appear in blacklist.
func narrowWhitelistByBlacklist(wl []int, bl []int) []int {
// Map the blacklist into a hash map.
var blacklist = map[int]interface{}{}
for _, id := range bl {
blacklist[id] = nil
}
// Limit the whitelist by the blacklist.
var result []int
for _, id := range wl {
if _, ok := blacklist[id]; !ok {
result = append(result, id)
}
}
return result
}
// GetArchive queries the archive view of the blog.
// Set private=true to return private posts, false returns public only.
func (m postMan) GetArchive(private bool) ([]*PostArchive, error) {
var result = []*PostArchive{}
query := DB.Table("posts").
Select("title, fragment, thumbnail, created_at, privacy")
if !private {
query = query.Where("privacy=?", Public)
}
rows, err := query.
Order("created_at desc").
Rows()
if err != nil {
return result, err
}
// Group the posts by their month/year.
var months []string
var byMonth = map[string]*PostArchive{}
for rows.Next() {
var row Post
if err := rows.Scan(&row.Title, &row.Fragment, &row.Thumbnail, &row.CreatedAt, &row.Privacy); err != nil {
return result, err
}
label := row.CreatedAt.Format("2006-01")
if _, ok := byMonth[label]; !ok {
months = append(months, label)
byMonth[label] = &PostArchive{
Label: label,
Date: time.Date(
row.CreatedAt.Year(), row.CreatedAt.Month(), 1,
0, 0, 0, 0, time.UTC,
),
Posts: []Post{},
}
}
byMonth[label].Posts = append(byMonth[label].Posts, row)
}
for _, month := range months {
result = append(result, byMonth[month])
}
return result, nil
}
// 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[int]int, error) {
var result = map[int]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[postID] = count
}
}
return result, nil
}
// ParseMultitag parses a tag string to return arrays for IN and NOT IN queries from DB.
//
// Example input: "blog,updates,-photo,-ask"
// Returns: ["blog", "updates"], ["photo", "ask"]
func ParseMultitag(tagline string) (whitelist, blacklist []string, err error) {
words := strings.Split(tagline, ",")
for _, word := range words {
word = strings.TrimSpace(word)
if len(word) == 0 {
continue
}
// Negation
if strings.HasPrefix(word, "-") {
blacklist = append(blacklist, strings.TrimPrefix(word, "-"))
} else {
whitelist = append(whitelist, word)
}
}
if len(whitelist) == 0 && len(blacklist) == 0 {
err = errors.New("parsing error")
}
return
}
// 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 <snip> tag for index views.
func (p Post) PreviewHTML() template.HTML {
var (
parts = strings.Split(p.Body, "<snip>")
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)
}
body = markdown.RenderTrustedMarkdown(body)
} else if hasMore {
body += fmt.Sprintf(`<p><a href="/%s">Read more...</a></p>`, p.Fragment)
}
// Make all images lazy loaded. TODO: make this configurable behavior?
body = mogrify.LazyLoadImages(body)
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, "<snip>", "")
if p.ContentType == Markdown {
body = markdown.RenderTrustedMarkdown(body)
}
// Make all images lazy loaded. TODO: make this configurable behavior?
body = mogrify.LazyLoadImages(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
}
// SetUpdated force sets the updated time of a post, i.e. to reset it to original.
func (p Post) SetUpdated(dt time.Time) error {
r := DB.Table("posts").Where("id = ?", p.ID).Updates(map[string]interface{}{
"updated_at": dt,
})
return r.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
}