Noah
8f98e72e47
* Post.HTML() and PreviewHTML() both mogrify the resulting HTML code to ensure all <img> tags have loading="lazy" unless a loading attribute is already present.
547 lines
13 KiB
Go
547 lines
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
|
|
}
|