376 lines
9.0 KiB
Go
376 lines
9.0 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"
|
|
"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
|
|
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
|
|
}
|
|
|
|
// 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 <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)
|
|
}
|
|
return template.HTML(markdown.RenderTrustedMarkdown(body))
|
|
}
|
|
|
|
body += fmt.Sprintf(`<p><a href="/%s">Read more...</a></p>`, 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, "<snip>", "")
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|