aboutsummaryrefslogblamecommitdiffstats
path: root/post/post.go
blob: 6ce195b45251e325e0c9f0ef172165c21af65e20 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11






                                                                                                     



                                    


























                                                  
                      



















































































                                                                                                                                               
                      





                                                                          


                                                     










































                                                                            







































                                                                                                                     







                                                                  













































                                                                                                                                    
/*
Package post provides the data and behavior for the fundamental atomic
unit of a site: a post. Posts are represented as text files, then converted to HTML and other formats
*/
package post

import (
	"adammathes.com/snkt/config"
	"adammathes.com/snkt/render"
	"adammathes.com/snkt/text"
	"adammathes.com/snkt/vlog"
	"github.com/russross/blackfriday"
	"io/ioutil"
	"log"
	"os"
	"path"
	"strconv"
	"strings"
	"time"
)

var Template = "post"

type Post struct {
	// Representations of the entire post text
	Raw      []byte
	Unparsed string

	// Metadata
	Meta       map[string]string
	SourceFile string
	Title      string `json:"title"`
	Permalink  string `json:"permalink"`
	Time       time.Time
	Year       int
	Month      time.Month
	Day        int
	InFuture   bool
	WordCount  int

	// Content text -- raw, unprocessed, unfiltered markdown
	Text string

	// Content text -- processed into HTML via markdown and other filters
	Content string

	// Content with sources and references resolved to absolute URLs
	AbsoluteContent string

	// Post following chronologically (later)
	Next *Post
	// Post preceding chronologically (earlier)
	Prev *Post

	// Precomputed dates as strings
	Date    string
	RssDate string

	FileInfo os.FileInfo

	Site sitemeta
}

type sitemeta interface {
	GetURL() string
	GetTitle() string
}

type Posts []*Post

func (posts Posts) Len() int {
	return len(posts)
}

func (posts Posts) Less(i, j int) bool {
	return posts[i].Time.Before(posts[j].Time)
}

func (s Posts) Swap(i, j int) {
	s[i], s[j] = s[j], s[i]
}

func NewPost(s sitemeta) *Post {
	var p Post
	p.Site = s
	return &p
}

/*
Read reads a post from file fi, and parses it into the Post struct, performing any work needed to fully populate the struct
*/
func (p *Post) Read(fi os.FileInfo) {
	p.Meta = make(map[string]string)
	p.FileInfo = fi
	p.SourceFile = p.FileInfo.Name()
	var err error
	p.Raw, err = ioutil.ReadFile(path.Join(config.Config.TxtDir, fi.Name()))
	if err != nil {
		log.Println(err)
	}
	p.Unparsed = string(p.Raw)
	p.parse()
}

/*
Parse parses the metadata prefix from the top of the post file's raw bytes, and puts the rest in the text segment. Meta is a name:value mapping
Title, date and other metadata are derived
*/
func (p *Post) parse() {
	//
	// Text + Meta[string][string]
	//
	p.splitTextMeta()

	//
	// Title
	//
	p.Title = p.Meta["title"]
	// Use filename as backup fi we have no explicit title
	if p.Title == "" {
		p.Title = p.SourceFile
	}

	p.parseDates()

	//
	// Content
	//
	p.Content = string(p.Filter([]byte(p.Text)))
	p.AbsoluteContent = render.ResolveURLs(p.Content, p.Site.GetURL())

	// WordCount
	p.WordCount = len(strings.Split(p.Text, " "))
}

/*
splitText splits up p.Unparsed into p.Text and p.Meta[attr][value]
*/
func (p *Post) splitTextMeta() {
	SEPARATOR := ":"
	lines := strings.Split(p.Unparsed, "\n")
	for _, line := range lines {
		if !strings.Contains(line, SEPARATOR) {
			break
		}
		splitdex := strings.Index(line, SEPARATOR)
		attr := strings.ToLower(strings.TrimSpace(line[0:splitdex]))
		value := strings.TrimSpace(line[splitdex+1:])
		p.Meta[attr] = value
	}
	p.Text = strings.Join(lines[len(p.Meta):], "\n")
}

func (p *Post) ParseFmt(s string) string {
	// TODO: document and add strftime like formats
	s = strings.Replace(s, "%Y", strconv.Itoa(p.Year), -1)
	s = strings.Replace(s, "%M", strconv.Itoa(int(p.Month)), -1)
	s = strings.Replace(s, "%D", strconv.Itoa(p.Day), -1)
	s = strings.Replace(s, "%F", p.CleanFilename(), -1)
	s = strings.Replace(s, "%T", p.CleanTitle(), -1)

	s = strings.Replace(s, "$Y", strconv.Itoa(p.Year), -1)
	s = strings.Replace(s, "$M", strconv.Itoa(int(p.Month)), -1)
	s = strings.Replace(s, "$D", strconv.Itoa(p.Day), -1)
	s = strings.Replace(s, "$F", p.CleanFilename(), -1)
	s = strings.Replace(s, "$T", p.CleanTitle(), -1)

	s = strings.Replace(s, ".File", p.CleanFilename(), -1)
	s = strings.Replace(s, ".Title", p.CleanTitle(), -1)
	s = strings.Replace(s, ".Year", strconv.Itoa(p.Year), -1)
	s = strings.Replace(s, ".Month", strconv.Itoa(int(p.Month)), -1)
	s = strings.Replace(s, ".Day", strconv.Itoa(p.Day), -1)

	return s
}

func (p *Post) parseDates() {
	//
	// Dates
	//
	// we only deal with yyyy-mm-dd [some legacy dates from my archives have times tacked on]
	// TODO: recover from empty dates/titles
	// TODO: probably should actually use times when present and clean up my archives
	var date_str = ""
	ds := strings.Fields(p.Meta["date"])
	if len(ds) > 0 {
		date_str = ds[0]
	}

	if date_str == "" {
		p.Time = p.FileInfo.ModTime()
		vlog.Printf("no date field in post %s, using file modification time\n", p.SourceFile)
	} else {
		var err error
		p.Time, err = time.ParseInLocation("2006-1-2", date_str, time.Local)
		if err != nil {
			// fallback is to use file modtime
			// should use create time but that doesn't seem to be in stdlib
			// TODO: figure out how to use file birth time
			vlog.Printf("no valid date parsed for post %s, using file modification time\n", p.SourceFile)
			p.Time = p.FileInfo.ModTime()
		}
	}

	p.Year, p.Month, p.Day = p.Time.Date()
	/* golang date format refresher
	      1 2  3  4  5  7     6
	Mon Jan 2 15:04:05 MST 2006 */

	p.Date = p.Time.Format("January 2, 2006")
	p.RssDate = p.Time.Format(time.RFC822)
	p.InFuture = time.Now().Before(p.Time)
	p.Permalink = p.GenPermalink()
}


func (p *Post) CleanFilename() string {
	return text.SanitizeFilename(text.RemoveExt(p.SourceFile))
}

func (p *Post) CleanTitle() string {
	return text.SanitizeFilename(p.Title)
}

/*
GenPermalink generates the permalink for the post given the PermalinkFmt format specified in the configuration file.
*/
func (p *Post) GenPermalink() string {
	pl := config.Config.PermalinkFmt
	return p.ParseFmt(pl)
}

/*
Target returns a string representing the file system location to write the output file representing the post.
*/
func (p Post) Target() string {
	pf := config.Config.PostFileFmt
	return path.Join(config.Config.HtmlDir, p.ParseFmt(pf))
}

/*
Render returns the post rendered as HTML via the post template with Post and Site as context.
*/
func (p Post) Render() []byte {
	data := struct {
		Post interface{}
		Site interface{}
	}{&p, &p.Site}
	return render.Render(Template, data)
}

/*
Filter runs the text through filters defined by render.Filter and marddown, returning text suitable for HTML output.
*/
func (p *Post) Filter(txt []byte) []byte {
	txt = render.Filter(txt)
	txt = blackfriday.MarkdownCommon(txt)
	return txt
}

/*
Limit returns a slice of Posts up to the int limit provided. If the limit is larger than the slice, it just returns the whole slice.
*/
func (posts Posts) Limit(limit int) Posts {
	if len(posts) < limit {
		return posts
	} else {
		return posts[0:limit]
	}
}