226 lines
5.1 KiB
Go
226 lines
5.1 KiB
Go
package posts
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kirsle/blog/core/jsondb"
|
|
"github.com/kirsle/golog"
|
|
)
|
|
|
|
// DB is a reference to the parent app's JsonDB object.
|
|
var DB *jsondb.DB
|
|
|
|
var log *golog.Logger
|
|
|
|
func init() {
|
|
log = golog.GetLogger("blog")
|
|
}
|
|
|
|
// Post holds information for a blog post.
|
|
type Post struct {
|
|
ID int `json:"id"`
|
|
Title string `json:"title"`
|
|
Fragment string `json:"fragment"`
|
|
ContentType string `json:"contentType"`
|
|
AuthorID int `json:"author"`
|
|
Body string `json:"body,omitempty"`
|
|
Privacy string `json:"privacy"`
|
|
Sticky bool `json:"sticky"`
|
|
EnableComments bool `json:"enableComments"`
|
|
Tags []string `json:"tags"`
|
|
Created time.Time `json:"created"`
|
|
Updated time.Time `json:"updated"`
|
|
}
|
|
|
|
// ByFragment maps a blog post by its URL fragment.
|
|
type ByFragment struct {
|
|
ID int `json:"id"`
|
|
}
|
|
|
|
// New creates a blank post with sensible defaults.
|
|
func New() *Post {
|
|
return &Post{
|
|
ContentType: "markdown",
|
|
Privacy: "public",
|
|
EnableComments: true,
|
|
}
|
|
}
|
|
|
|
// ParseForm populates the post from form values.
|
|
func (p *Post) ParseForm(r *http.Request) {
|
|
id, _ := strconv.Atoi(r.FormValue("id"))
|
|
|
|
p.ID = id
|
|
p.Title = r.FormValue("title")
|
|
p.Fragment = r.FormValue("fragment")
|
|
p.ContentType = r.FormValue("content-type")
|
|
p.Body = r.FormValue("body")
|
|
p.Privacy = r.FormValue("privacy")
|
|
p.Sticky = r.FormValue("sticky") == "true"
|
|
p.EnableComments = r.FormValue("enable-comments") == "true"
|
|
|
|
// Ingest the tags.
|
|
tags := strings.Split(r.FormValue("tags"), ",")
|
|
p.Tags = []string{}
|
|
for _, tag := range tags {
|
|
p.Tags = append(p.Tags, strings.TrimSpace(tag))
|
|
}
|
|
}
|
|
|
|
// Validate makes sure the required fields are all present.
|
|
func (p *Post) Validate() error {
|
|
if p.Title == "" {
|
|
return errors.New("title is required")
|
|
}
|
|
if p.ContentType != "markdown" && p.ContentType != "markdown+html" &&
|
|
p.ContentType != "html" {
|
|
return errors.New("invalid setting for ContentType")
|
|
}
|
|
if p.Privacy != "public" && p.Privacy != "draft" && p.Privacy != "private" && p.Privacy != "unlisted" {
|
|
return errors.New("invalid setting for Privacy")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Load a post by its ID.
|
|
func Load(id int) (*Post, error) {
|
|
p := &Post{}
|
|
err := DB.Get(fmt.Sprintf("blog/posts/%d", id), &p)
|
|
return p, err
|
|
}
|
|
|
|
// LoadFragment loads a blog entry by its URL fragment.
|
|
func LoadFragment(fragment string) (*Post, error) {
|
|
f := ByFragment{}
|
|
err := DB.Get("blog/fragments/"+fragment, &f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
p, err := Load(f.ID)
|
|
return p, err
|
|
}
|
|
|
|
// Save the blog post.
|
|
func (p *Post) Save() error {
|
|
// Editing an existing post?
|
|
if p.ID == 0 {
|
|
p.ID = p.nextID()
|
|
}
|
|
|
|
// Generate a URL fragment if needed.
|
|
if p.Fragment == "" {
|
|
fragment := strings.ToLower(p.Title)
|
|
fragment = regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(fragment, "-")
|
|
if strings.Contains(fragment, "--") {
|
|
log.Error("Generated blog fragment '%s' contains double dashes still!", fragment)
|
|
}
|
|
p.Fragment = strings.Trim(fragment, "-")
|
|
|
|
// If still no fragment, make one based on the post ID.
|
|
if p.Fragment == "" {
|
|
p.Fragment = fmt.Sprintf("post-%d", p.ID)
|
|
}
|
|
}
|
|
|
|
// Make sure the URL fragment is unique!
|
|
if len(p.Fragment) > 0 {
|
|
if exist, err := LoadFragment(p.Fragment); err == nil && exist.ID != p.ID {
|
|
var resolved bool
|
|
for i := 1; i <= 100; i++ {
|
|
fragment := fmt.Sprintf("%s-%d", p.Fragment, i)
|
|
_, err := 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dates & times.
|
|
if p.Created.IsZero() {
|
|
p.Created = time.Now().UTC()
|
|
}
|
|
if p.Updated.IsZero() {
|
|
p.Updated = p.Created
|
|
}
|
|
|
|
// Empty tag lists.
|
|
if len(p.Tags) == 1 && p.Tags[0] == "" {
|
|
p.Tags = []string{}
|
|
}
|
|
|
|
// Write the post.
|
|
DB.Commit(fmt.Sprintf("blog/posts/%d", p.ID), p)
|
|
DB.Commit(fmt.Sprintf("blog/fragments/%s", p.Fragment), ByFragment{p.ID})
|
|
|
|
// Update the index cache.
|
|
err := UpdateIndex(p)
|
|
if err != nil {
|
|
return fmt.Errorf("RebuildIndex() error: %v", err)
|
|
}
|
|
|
|
// Clean up fragments.
|
|
CleanupFragments()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Delete a blog entry.
|
|
func (p *Post) Delete() error {
|
|
if p.ID == 0 {
|
|
return errors.New("post has no ID")
|
|
}
|
|
|
|
// Delete the DB files.
|
|
DB.Delete(fmt.Sprintf("blog/posts/%d", p.ID))
|
|
DB.Delete(fmt.Sprintf("blog/fragments/%s", p.Fragment))
|
|
|
|
// Remove it from the index.
|
|
idx, err := GetIndex()
|
|
if err != nil {
|
|
return fmt.Errorf("GetIndex error: %v", err)
|
|
}
|
|
return idx.Delete(p)
|
|
}
|
|
|
|
// getNextID gets the next blog post ID.
|
|
func (p *Post) nextID() int {
|
|
// Highest ID seen so far.
|
|
var highest int
|
|
|
|
posts, err := DB.List("blog/posts")
|
|
if err != nil {
|
|
return 1
|
|
}
|
|
|
|
for _, doc := range posts {
|
|
fields := strings.Split(doc, "/")
|
|
id, err := strconv.Atoi(fields[len(fields)-1])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if id > highest {
|
|
highest = id
|
|
}
|
|
}
|
|
|
|
// Return the highest +1
|
|
return highest + 1
|
|
}
|