314 lines
7.5 KiB
Go
314 lines
7.5 KiB
Go
|
package models
|
||
|
|
||
|
import (
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"html/template"
|
||
|
"math"
|
||
|
"regexp"
|
||
|
"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"`
|
||
|
}
|
||
|
|
||
|
// 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
|
||
|
}
|
||
|
|
||
|
// TaggedPost associates tags to their posts.
|
||
|
type TaggedPost struct {
|
||
|
ID uint `gorm:"primary_key"`
|
||
|
Tag string
|
||
|
PostID uint // foreign key to Post
|
||
|
}
|
||
|
|
||
|
// 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((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
|
||
|
}
|
||
|
|
||
|
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
|
||
|
}
|
||
|
|
||
|
return pp, r.Error
|
||
|
}
|
||
|
|
||
|
// 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
|
||
|
}
|