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) continue } 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 } 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 tag for index views. func (p Post) PreviewHTML() template.HTML { var ( parts = strings.Split(p.Body, "") 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(`

Read more...

`, 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, "", "") 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, "--", "-") p.Fragment = strings.Trim(fragment, "-") console.Error("frag: %s", p.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! { console.Debug("Ensuring fragment '%s' is unique", p.Fragment) 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) exist, err = Posts.LoadFragment(fragment) if err == nil && exist.ID != p.ID { 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 }