GoBlog/postsDb.go

675 lines
20 KiB
Go

package main
import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"text/template"
"time"
"github.com/araddon/dateparse"
"github.com/samber/lo"
"go.goblog.app/app/pkgs/bufferpool"
)
func (a *goBlog) checkPost(p *post) (err error) {
if p == nil {
return errors.New("no post")
}
now := time.Now()
// Fix content
p.Content = strings.TrimSuffix(strings.TrimPrefix(p.Content, "\n"), "\n")
// Fix date strings
if p.Published != "" {
p.Published, err = toLocal(p.Published)
if err != nil {
return err
}
}
if p.Updated != "" {
p.Updated, err = toLocal(p.Updated)
if err != nil {
return err
}
}
// Check status
if p.Status == "" {
p.Status = statusPublished
if p.Published != "" {
// If published time is in the future, set status to scheduled
publishedTime, err := dateparse.ParseLocal(p.Published)
if err != nil {
return err
}
if publishedTime.After(time.Now()) {
p.Status = statusScheduled
}
}
}
// Cleanup params
for key, value := range p.Parameters {
if value == nil {
delete(p.Parameters, key)
continue
}
allValues := []string{}
for _, v := range value {
if v != "" {
allValues = append(allValues, v)
}
}
if len(allValues) >= 1 {
p.Parameters[key] = allValues
} else {
delete(p.Parameters, key)
}
}
// Check blog
if p.Blog == "" {
p.Blog = a.cfg.DefaultBlog
}
if _, ok := a.cfg.Blogs[p.Blog]; !ok {
return errors.New("blog doesn't exist")
}
// Check if section exists
if _, ok := a.cfg.Blogs[p.Blog].Sections[p.Section]; p.Section != "" && !ok {
return errors.New("section doesn't exist")
}
// Check path
if p.Path != "/" {
p.Path = strings.TrimSuffix(p.Path, "/")
}
if p.Path == "" {
if p.Section == "" {
p.Section = a.cfg.Blogs[p.Blog].DefaultSection
}
if p.Slug == "" {
p.Slug = fmt.Sprintf("%v-%02d-%02d-%v", now.Year(), int(now.Month()), now.Day(), randomString(5))
}
published := timeNoErr(dateparse.ParseLocal(p.Published))
pathTmplString := defaultIfEmpty(
a.cfg.Blogs[p.Blog].Sections[p.Section].PathTemplate,
"{{printf \""+a.getRelativePath(p.Blog, "/%v/%02d/%02d/%v")+"\" .Section .Year .Month .Slug}}",
)
pathTmpl, err := template.New("location").Parse(pathTmplString)
if err != nil {
return errors.New("failed to parse location template")
}
pathBuffer := bufferpool.Get()
defer bufferpool.Put(pathBuffer)
err = pathTmpl.Execute(pathBuffer, map[string]any{
"BlogPath": a.getRelativePath(p.Blog, ""),
"Year": published.Year(),
"Month": int(published.Month()),
"Day": published.Day(),
"Slug": p.Slug,
"Section": p.Section,
})
if err != nil {
return errors.New("failed to execute location template")
}
p.Path = pathBuffer.String()
}
if p.Path != "" && !strings.HasPrefix(p.Path, "/") {
return errors.New("wrong path")
}
return nil
}
func (a *goBlog) createPost(p *post) error {
return a.createOrReplacePost(p, &postCreationOptions{new: true})
}
func (a *goBlog) replacePost(p *post, oldPath string, oldStatus postStatus) error {
return a.createOrReplacePost(p, &postCreationOptions{new: false, oldPath: oldPath, oldStatus: oldStatus})
}
type postCreationOptions struct {
new bool
oldPath string
oldStatus postStatus
}
func (a *goBlog) createOrReplacePost(p *post, o *postCreationOptions) error {
// Check post
if err := a.checkPost(p); err != nil {
return err
}
// Save to db
if err := a.db.savePost(p, o); err != nil {
return err
}
// Reload post from database
p, err := a.getPost(p.Path)
if err != nil {
// Failed to reload post from database
return err
}
// Trigger hooks
if p.Status == statusPublished || p.Status == statusUnlisted {
if o.new || (o.oldStatus != statusPublished && o.oldStatus != statusUnlisted) {
defer a.postPostHooks(p)
} else {
defer a.postUpdateHooks(p)
}
}
// Purge cache
a.cache.purge()
a.deleteReactionsCache(p.Path)
return nil
}
// Save check post to database
func (db *database) savePost(p *post, o *postCreationOptions) error {
// Check
if !o.new && o.oldPath == "" {
return errors.New("old path required")
}
// Lock post creation
db.pcm.Lock()
defer db.pcm.Unlock()
// Build SQL
sqlBuilder := bufferpool.Get()
defer bufferpool.Put(sqlBuilder)
var sqlArgs = []any{dbNoCache}
// Start transaction
sqlBuilder.WriteString("begin;")
// Update or create post
if o.new {
// New post, create it
sqlBuilder.WriteString("insert into posts (path, content, published, updated, blog, section, status, priority) values (?, ?, ?, ?, ?, ?, ?, ?);")
sqlArgs = append(sqlArgs, p.Path, p.Content, toUTCSafe(p.Published), toUTCSafe(p.Updated), p.Blog, p.Section, p.Status, p.Priority)
} else {
// Update old post
sqlBuilder.WriteString("update posts set path = ?, content = ?, published = ?, updated = ?, blog = ?, section = ?, status = ?, priority = ? where path = ?;")
sqlArgs = append(sqlArgs, p.Path, p.Content, toUTCSafe(p.Published), toUTCSafe(p.Updated), p.Blog, p.Section, p.Status, p.Priority, o.oldPath)
// Delete post parameters
sqlBuilder.WriteString("delete from post_parameters where path = ?;")
sqlArgs = append(sqlArgs, o.oldPath)
}
// Insert post parameters
for param, value := range p.Parameters {
for _, value := range value {
if value != "" {
sqlBuilder.WriteString("insert into post_parameters (path, parameter, value) values (?, ?, ?);")
sqlArgs = append(sqlArgs, p.Path, param, value)
}
}
}
// Commit transaction
sqlBuilder.WriteString("commit;")
// Execute
if _, err := db.exec(sqlBuilder.String(), sqlArgs...); err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed: posts.path") {
return errors.New("post already exists at given path")
}
return err
}
// Update FTS index
db.rebuildFTSIndex()
return nil
}
func (a *goBlog) deletePost(path string) error {
if path == "" {
return errors.New("path required")
}
// Lock post creation
a.db.pcm.Lock()
defer a.db.pcm.Unlock()
// Check if post exists
p, err := a.getPost(path)
if err != nil {
return err
}
// Post exists, check if it's already marked as deleted
if strings.HasSuffix(string(p.Status), statusDeletedSuffix) {
// Post is already marked as deleted, delete it from database
if _, err = a.db.exec(
`begin; delete from posts where path = ?; delete from post_parameters where path = ?; insert or ignore into deleted (path) values (?); commit;`,
dbNoCache, p.Path, p.Path, p.Path,
); err != nil {
return err
}
// Rebuild FTS index
a.db.rebuildFTSIndex()
// Purge cache
a.cache.purge()
a.deleteReactionsCache(p.Path)
} else {
// Update post status
p.Status = postStatus(string(p.Status) + statusDeletedSuffix)
// Add parameter
deletedTime := utcNowString()
if p.Parameters == nil {
p.Parameters = map[string][]string{}
}
p.Parameters["deleted"] = []string{deletedTime}
// Mark post as deleted
if _, err = a.db.exec(
`begin; update posts set status = ? where path = ?; delete from post_parameters where path = ? and value = 'deleted'; insert into post_parameters (path, parameter, value) values (?, 'deleted', ?); commit;`,
dbNoCache, p.Status, p.Path, p.Path, p.Path, deletedTime,
); err != nil {
return err
}
// Rebuild FTS index
a.db.rebuildFTSIndex()
// Purge cache
a.cache.purge()
// Trigger hooks
a.postDeleteHooks(p)
}
return nil
}
func (a *goBlog) undeletePost(path string) error {
if path == "" {
return errors.New("path required")
}
// Lock post creation
a.db.pcm.Lock()
defer a.db.pcm.Unlock()
// Check if post exists
p, err := a.getPost(path)
if err != nil {
return err
}
// Post exists, update status and parameters
p.Status = postStatus(strings.TrimSuffix(string(p.Status), statusDeletedSuffix))
// Remove parameter
p.Parameters["deleted"] = nil
// Update database
if _, err = a.db.exec(
`begin; update posts set status = ? where path = ?; delete from post_parameters where path = ? and parameter = 'deleted'; commit;`,
dbNoCache, p.Status, p.Path, p.Path,
); err != nil {
return err
}
// Rebuild FTS index
a.db.rebuildFTSIndex()
// Purge cache
a.cache.purge()
// Trigger hooks
a.postUndeleteHooks(p)
return nil
}
func (db *database) replacePostParam(path, param string, values []string) error {
// Filter empty values
values = lo.Filter(values, func(v string, _ int) bool { return v != "" })
// Lock post creation
db.pcm.Lock()
defer db.pcm.Unlock()
// Build SQL
sqlBuilder := bufferpool.Get()
var sqlArgs = []any{dbNoCache}
// Start transaction
sqlBuilder.WriteString("begin;")
// Delete old post
sqlBuilder.WriteString("delete from post_parameters where path = ? and parameter = ?;")
sqlArgs = append(sqlArgs, path, param)
// Insert new post parameters
for _, value := range values {
sqlBuilder.WriteString("insert into post_parameters (path, parameter, value) values (?, ?, ?);")
sqlArgs = append(sqlArgs, path, param, value)
}
// Commit transaction
sqlBuilder.WriteString("commit;")
// Execute
_, err := db.exec(sqlBuilder.String(), sqlArgs...)
bufferpool.Put(sqlBuilder)
if err != nil {
return err
}
// Update FTS index
db.rebuildFTSIndex()
return nil
}
type postsRequestConfig struct {
search string
blog string
path string
limit int
offset int
sections []string
status postStatus
statusse []postStatus
taxonomy *configTaxonomy
taxonomyValue string
parameters []string // Ignores parameterValue
parameter string // Ignores parameters
parameterValue string
publishedYear, publishedMonth, publishedDay int
publishedBefore time.Time
randomOrder bool
priorityOrder bool
withoutParameters bool
withOnlyParameters []string
withoutRenderedTitle bool
}
func buildPostsQuery(c *postsRequestConfig, selection string) (query string, args []any) {
queryBuilder := bufferpool.Get()
defer bufferpool.Put(queryBuilder)
// Selection
queryBuilder.WriteString("select ")
queryBuilder.WriteString(selection)
queryBuilder.WriteString(" from ")
// Table
if c.search != "" {
queryBuilder.WriteString("(select p.* from posts_fts(@search) ps, posts p where ps.path = p.path)")
args = append(args, sql.Named("search", c.search))
} else {
queryBuilder.WriteString("posts")
}
// Filter
queryBuilder.WriteString(" where 1")
if c.path != "" {
queryBuilder.WriteString(" and path = @path")
args = append(args, sql.Named("path", c.path))
}
if c.status != "" && c.status != statusNil {
queryBuilder.WriteString(" and status = @status")
args = append(args, sql.Named("status", c.status))
}
if c.statusse != nil && len(c.statusse) > 0 {
queryBuilder.WriteString(" and status in (")
for i, status := range c.statusse {
if i > 0 {
queryBuilder.WriteString(", ")
}
named := "status" + strconv.Itoa(i)
queryBuilder.WriteByte('@')
queryBuilder.WriteString(named)
args = append(args, sql.Named(named, status))
}
queryBuilder.WriteByte(')')
}
if c.blog != "" {
queryBuilder.WriteString(" and blog = @blog")
args = append(args, sql.Named("blog", c.blog))
}
if c.parameter != "" {
if c.parameterValue != "" {
queryBuilder.WriteString(" and path in (select path from post_parameters where parameter = @param and value = @paramval)")
args = append(args, sql.Named("param", c.parameter), sql.Named("paramval", c.parameterValue))
} else {
queryBuilder.WriteString(" and path in (select path from post_parameters where parameter = @param and length(coalesce(value, '')) > 0)")
args = append(args, sql.Named("param", c.parameter))
}
} else if len(c.parameters) > 0 {
queryBuilder.WriteString(" and path in (select path from post_parameters where parameter in (")
for i, param := range c.parameters {
if i > 0 {
queryBuilder.WriteString(", ")
}
named := "param" + strconv.Itoa(i)
queryBuilder.WriteByte('@')
queryBuilder.WriteString(named)
args = append(args, param)
}
queryBuilder.WriteString(") and length(coalesce(value, '')) > 0)")
}
if c.taxonomy != nil && len(c.taxonomyValue) > 0 {
queryBuilder.WriteString(" and path in (select path from post_parameters where parameter = @taxname and lowerx(value) = lowerx(@taxval))")
args = append(args, sql.Named("taxname", c.taxonomy.Name), sql.Named("taxval", c.taxonomyValue))
}
if len(c.sections) > 0 {
queryBuilder.WriteString(" and section in (")
for i, section := range c.sections {
if i > 0 {
queryBuilder.WriteString(", ")
}
named := "section" + strconv.Itoa(i)
queryBuilder.WriteByte('@')
queryBuilder.WriteString(named)
args = append(args, sql.Named(named, section))
}
queryBuilder.WriteByte(')')
}
if c.publishedYear != 0 {
queryBuilder.WriteString(" and substr(tolocal(published), 1, 4) = @publishedyear")
args = append(args, sql.Named("publishedyear", fmt.Sprintf("%0004d", c.publishedYear)))
}
if c.publishedMonth != 0 {
queryBuilder.WriteString(" and substr(tolocal(published), 6, 2) = @publishedmonth")
args = append(args, sql.Named("publishedmonth", fmt.Sprintf("%02d", c.publishedMonth)))
}
if c.publishedDay != 0 {
queryBuilder.WriteString(" and substr(tolocal(published), 9, 2) = @publishedday")
args = append(args, sql.Named("publishedday", fmt.Sprintf("%02d", c.publishedDay)))
}
if !c.publishedBefore.IsZero() {
queryBuilder.WriteString(" and toutc(published) < @publishedbefore")
args = append(args, sql.Named("publishedbefore", c.publishedBefore.UTC().Format(time.RFC3339)))
}
// Order
queryBuilder.WriteString(" order by ")
if c.randomOrder {
queryBuilder.WriteString("random()")
} else if c.priorityOrder {
queryBuilder.WriteString("priority desc, published desc")
} else {
queryBuilder.WriteString("published desc")
}
// Limit & Offset
if c.limit != 0 || c.offset != 0 {
queryBuilder.WriteString(" limit @limit offset @offset")
args = append(args, sql.Named("limit", c.limit), sql.Named("offset", c.offset))
}
return queryBuilder.String(), args
}
func (d *database) loadPostParameters(posts []*post, parameters ...string) (err error) {
if len(posts) == 0 {
return nil
}
// Build query
sqlArgs := make([]any, 0)
queryBuilder := bufferpool.Get()
defer bufferpool.Put(queryBuilder)
queryBuilder.WriteString("select path, parameter, value from post_parameters where")
// Paths
queryBuilder.WriteString(" path in (")
for i, p := range posts {
if i > 0 {
queryBuilder.WriteString(", ")
}
named := "path" + strconv.Itoa(i)
queryBuilder.WriteByte('@')
queryBuilder.WriteString(named)
sqlArgs = append(sqlArgs, sql.Named(named, p.Path))
}
queryBuilder.WriteByte(')')
// Parameters
if len(parameters) > 0 {
queryBuilder.WriteString(" and parameter in (")
for i, p := range parameters {
if i > 0 {
queryBuilder.WriteString(", ")
}
named := "param" + strconv.Itoa(i)
queryBuilder.WriteByte('@')
queryBuilder.WriteString(named)
sqlArgs = append(sqlArgs, sql.Named(named, p))
}
queryBuilder.WriteByte(')')
}
// Order
queryBuilder.WriteString(" order by id")
// Query
rows, err := d.query(queryBuilder.String(), sqlArgs...)
if err != nil {
return err
}
// Result
var path, name, value string
params := map[string]map[string][]string{}
for rows.Next() {
if err = rows.Scan(&path, &name, &value); err != nil {
return err
}
m, ok := params[path]
if !ok {
m = map[string][]string{}
}
m[name] = append(m[name], value)
params[path] = m
}
// Add to posts
for _, p := range posts {
p.Parameters = params[p.Path]
}
return nil
}
func (a *goBlog) getPosts(config *postsRequestConfig) (posts []*post, err error) {
// Query posts
query, queryParams := buildPostsQuery(config, "path, coalesce(content, ''), coalesce(published, ''), coalesce(updated, ''), blog, coalesce(section, ''), status, priority")
rows, err := a.db.query(query, queryParams...)
if err != nil {
return nil, err
}
// Prepare row scanning
var path, content, published, updated, blog, section, status string
var priority int
for rows.Next() {
if err = rows.Scan(&path, &content, &published, &updated, &blog, &section, &status, &priority); err != nil {
return nil, err
}
// Create new post, fill and add to list
p := &post{
Path: path,
Content: content,
Published: toLocalSafe(published),
Updated: toLocalSafe(updated),
Blog: blog,
Section: section,
Status: postStatus(status),
Priority: priority,
}
posts = append(posts, p)
}
if !config.withoutParameters {
err = a.db.loadPostParameters(posts, config.withOnlyParameters...)
if err != nil {
return nil, err
}
}
// Render post title
if !config.withoutRenderedTitle {
for _, p := range posts {
if t := p.Title(); t != "" {
p.RenderedTitle = a.renderMdTitle(t)
}
}
}
return posts, nil
}
func (a *goBlog) getPost(path string) (*post, error) {
posts, err := a.getPosts(&postsRequestConfig{path: path, limit: 1})
if err != nil {
return nil, err
} else if len(posts) == 0 {
return nil, errPostNotFound
}
return posts[0], nil
}
func (d *database) countPosts(config *postsRequestConfig) (count int, err error) {
query, params := buildPostsQuery(config, "path")
row, err := d.queryRow("select count(distinct path) from ("+query+")", params...)
if err != nil {
return
}
err = row.Scan(&count)
return
}
func (a *goBlog) getRandomPostPath(blog string) (path string, err error) {
sections := lo.Keys(a.cfg.Blogs[blog].Sections)
query, params := buildPostsQuery(&postsRequestConfig{randomOrder: true, limit: 1, blog: blog, sections: sections}, "path")
row, err := a.db.queryRow(query, params...)
if err != nil {
return
}
err = row.Scan(&path)
if errors.Is(err, sql.ErrNoRows) {
return "", errPostNotFound
} else if err != nil {
return "", err
}
return path, nil
}
func (d *database) allTaxonomyValues(blog string, taxonomy string) ([]string, error) {
var values []string
// TODO: Query posts the normal way
rows, err := d.query("select distinct value from post_parameters where parameter = @tax and length(coalesce(value, '')) > 0 and path in (select path from posts where blog = @blog and status = @status) order by value", sql.Named("tax", taxonomy), sql.Named("blog", blog), sql.Named("status", statusPublished))
if err != nil {
return nil, err
}
var value string
for rows.Next() {
if err = rows.Scan(&value); err != nil {
return nil, err
}
values = append(values, value)
}
return values, nil
}
const mediaUseSql = `
with mediafiles (name) as (values %s)
select name, count(path) as count from (
select distinct m.name, p.path
from mediafiles m, post_parameters p
where instr(p.value, m.name) > 0
union
select distinct m.name, p.path
from mediafiles m, posts_fts p
where p.content match '"' || m.name || '"'
)
group by name;
`
func (db *database) usesOfMediaFile(names ...string) (counts []int, err error) {
sqlArgs := []any{dbNoCache}
nameValues := bufferpool.Get()
for i, n := range names {
if i > 0 {
nameValues.WriteString(", ")
}
named := "name" + strconv.Itoa(i)
nameValues.WriteString("(@")
nameValues.WriteString(named)
nameValues.WriteByte(')')
sqlArgs = append(sqlArgs, sql.Named(named, n))
}
rows, err := db.query(fmt.Sprintf(mediaUseSql, nameValues.String()), sqlArgs...)
bufferpool.Put(nameValues)
if err != nil {
return nil, err
}
counts = make([]int, len(names))
var name string
var count int
for rows.Next() {
err = rows.Scan(&name, &count)
if err != nil {
return nil, err
}
for i, n := range names {
if n == name {
counts[i] = count
break
}
}
}
return counts, nil
}