Noah
b642562792
* Can query blog posts by multiple tags now. * e.g. /tagged/blog,updates,-photos would query all posts that have tags "blog" OR "updates" but NOT show any post with tag "photos"
516 lines
12 KiB
Go
516 lines
12 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"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|