A web blog and personal homepage engine written in Go.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

395 lines
9.6 KiB

  1. package models
  2. import (
  3. "errors"
  4. "fmt"
  5. "html/template"
  6. "math"
  7. "regexp"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "git.kirsle.net/apps/gophertype/pkg/console"
  12. "git.kirsle.net/apps/gophertype/pkg/markdown"
  13. "github.com/albrow/forms"
  14. "github.com/jinzhu/gorm"
  15. )
  16. type postMan struct{}
  17. // Posts is a singleton manager class for Post model access.
  18. var Posts = postMan{}
  19. // Post represents a single blog entry.
  20. type Post struct {
  21. gorm.Model
  22. Title string
  23. Fragment string `gorm:"unique_index"`
  24. ContentType string `gorm:"default:html"`
  25. AuthorID uint // foreign key to User.ID
  26. Thumbnail string // image thumbnail for the post
  27. Body string
  28. Privacy string
  29. Sticky bool
  30. EnableComments bool
  31. Tags []TaggedPost
  32. Author User `gorm:"foreign_key:UserID"`
  33. // Private fields not in DB.
  34. CommentCount int `gorm:"-"`
  35. }
  36. // PagedPosts holds a paginated response of multiple posts.
  37. type PagedPosts struct {
  38. Posts []Post
  39. Page int
  40. PerPage int
  41. Pages int
  42. Total int
  43. NextPage int
  44. PreviousPage int
  45. }
  46. // Regexp for matching a thumbnail image for a blog post.
  47. var ThumbnailImageRegexp = regexp.MustCompile(`['"(]([a-zA-Z0-9-_:/?.=&]+\.(?:jpe?g|png|gif))['")]`)
  48. // New creates a new Post model.
  49. func (m postMan) New() Post {
  50. return Post{
  51. ContentType: Markdown,
  52. Privacy: Public,
  53. EnableComments: true,
  54. }
  55. }
  56. // Load a post by ID.
  57. func (m postMan) Load(id int) (Post, error) {
  58. var post Post
  59. r := DB.Preload("Author").Preload("Tags").First(&post, id)
  60. return post, r.Error
  61. }
  62. // LoadFragment loads a blog post by its URL fragment.
  63. func (m postMan) LoadFragment(fragment string) (Post, error) {
  64. var post Post
  65. r := DB.Preload("Author").Preload("Tags").Where("fragment = ?", strings.Trim(fragment, "/")).First(&post)
  66. return post, r.Error
  67. }
  68. // GetIndex returns the index page of blog posts.
  69. func (m postMan) GetIndexPosts(privacy string, page, perPage int) (PagedPosts, error) {
  70. var pp = PagedPosts{
  71. Page: page,
  72. PerPage: perPage,
  73. }
  74. if pp.Page < 1 {
  75. pp.Page = 1
  76. }
  77. if pp.PerPage <= 0 {
  78. pp.PerPage = 20
  79. }
  80. query := DB.Debug().Preload("Author").Preload("Tags").
  81. Where("privacy = ?", privacy).
  82. Order("sticky desc, created_at desc")
  83. // Count the total number of rows for paging purposes.
  84. query.Model(&Post{}).Count(&pp.Total)
  85. // Query the paginated slice of results.
  86. r := query.
  87. Offset((pp.Page - 1) * pp.PerPage).
  88. Limit(pp.PerPage).
  89. Find(&pp.Posts)
  90. pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage)))
  91. if pp.Page < pp.Pages {
  92. pp.NextPage = pp.Page + 1
  93. }
  94. if pp.Page > 1 {
  95. pp.PreviousPage = pp.Page - 1
  96. }
  97. if err := pp.CountComments(); err != nil {
  98. console.Error("PagedPosts.CountComments: %s", err)
  99. }
  100. return pp, r.Error
  101. }
  102. // GetPostsByTag gets posts by a certain tag.
  103. func (m postMan) GetPostsByTag(tag, privacy string, page, perPage int) (PagedPosts, error) {
  104. var pp = PagedPosts{
  105. Page: page,
  106. PerPage: perPage,
  107. }
  108. if pp.Page < 1 {
  109. pp.Page = 1
  110. }
  111. if pp.PerPage <= 0 {
  112. pp.PerPage = 20
  113. }
  114. // Get the distinct post IDs for this tag.
  115. var tags []TaggedPost
  116. var postIDs []uint
  117. r := DB.Where("tag = ?", tag).Find(&tags)
  118. for _, taggedPost := range tags {
  119. postIDs = append(postIDs, taggedPost.PostID)
  120. }
  121. if len(postIDs) == 0 {
  122. return pp, errors.New("no posts found")
  123. }
  124. // Query this set of posts.
  125. query := DB.Debug().Preload("Author").Preload("Tags").
  126. Where("id IN (?) AND privacy = ?", postIDs, privacy).
  127. Order("sticky desc, created_at desc")
  128. // Count the total number of rows for paging purposes.
  129. query.Model(&Post{}).Count(&pp.Total)
  130. // Query the paginated slice of results.
  131. r = query.
  132. Offset((page - 1) * perPage).
  133. Limit(perPage).
  134. Find(&pp.Posts)
  135. pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage)))
  136. if pp.Page < pp.Pages {
  137. pp.NextPage = pp.Page + 1
  138. }
  139. if pp.Page > 1 {
  140. pp.PreviousPage = pp.Page - 1
  141. }
  142. if err := pp.CountComments(); err != nil {
  143. console.Error("PagedPosts.CountComments: %s", err)
  144. }
  145. return pp, r.Error
  146. }
  147. // CountComments gets comment counts for one or more posts.
  148. // Returns a map[uint]int mapping post ID to comment count.
  149. func (m postMan) CountComments(posts ...Post) (map[uint]int, error) {
  150. var result = map[uint]int{}
  151. // Create the comment thread IDs.
  152. var threadIDs = make([]string, len(posts))
  153. for i, post := range posts {
  154. threadIDs[i] = fmt.Sprintf("post-%d", post.ID)
  155. }
  156. // Query comment counts for each thread.
  157. if len(threadIDs) > 0 {
  158. rows, err := DB.Table("comments").
  159. Select("thread, count(*) as count").
  160. Group("thread").
  161. Rows()
  162. if err != nil {
  163. return result, err
  164. }
  165. for rows.Next() {
  166. var thread string
  167. var count int
  168. if err := rows.Scan(&thread, &count); err != nil {
  169. console.Error("CountComments: rows.Scan: %s", err)
  170. }
  171. postID, err := strconv.Atoi(strings.TrimPrefix(thread, "post-"))
  172. if err != nil {
  173. console.Warn("CountComments: strconv.Atoi(%s): %s", thread, err)
  174. }
  175. result[uint(postID)] = count
  176. }
  177. }
  178. return result, nil
  179. }
  180. // CountComments on the posts in a PagedPosts list.
  181. func (pp *PagedPosts) CountComments() error {
  182. counts, err := Posts.CountComments(pp.Posts...)
  183. if err != nil {
  184. return err
  185. }
  186. console.Info("counts: %+v", counts)
  187. for i, post := range pp.Posts {
  188. if count, ok := counts[post.ID]; ok {
  189. pp.Posts[i].CommentCount = count
  190. }
  191. }
  192. return nil
  193. }
  194. // PreviewHTML returns the post's body as rendered HTML code, but only above
  195. // the <snip> tag for index views.
  196. func (p Post) PreviewHTML() template.HTML {
  197. var (
  198. parts = strings.Split(p.Body, "<snip>")
  199. hasMore = len(parts) > 1
  200. body = strings.TrimSpace(parts[0])
  201. )
  202. if p.ContentType == Markdown {
  203. if hasMore {
  204. body += fmt.Sprintf("\n\n[Read more...](/%s)", p.Fragment)
  205. }
  206. return template.HTML(markdown.RenderTrustedMarkdown(body))
  207. }
  208. body += fmt.Sprintf(`<p><a href="/%s">Read more...</a></p>`, p.Fragment)
  209. return template.HTML(body)
  210. }
  211. // HTML returns the post's body as rendered HTML code.
  212. func (p Post) HTML() template.HTML {
  213. body := strings.ReplaceAll(p.Body, "<snip>", "")
  214. if p.ContentType == Markdown {
  215. return template.HTML(markdown.RenderTrustedMarkdown(body))
  216. }
  217. return template.HTML(body)
  218. }
  219. // Save a post.
  220. // This method also makes sure a unique Fragment is set and links the Tags correctly.
  221. func (p *Post) Save() error {
  222. // Generate the default fragment from the post title.
  223. if p.Fragment == "" {
  224. fragment := strings.ToLower(p.Title)
  225. fragment = regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(fragment, "-")
  226. fragment = strings.ReplaceAll(fragment, "--", "-")
  227. console.Error("frag: %s", fragment)
  228. p.Fragment = strings.Trim(fragment, "-")
  229. // If still no fragment, make one up from the current time.
  230. if p.Fragment == "" {
  231. p.Fragment = time.Now().Format("2006-01-02-150405")
  232. }
  233. }
  234. // Ensure the fragment is unique!
  235. {
  236. if exist, err := Posts.LoadFragment(p.Fragment); err != nil && exist.ID != p.ID {
  237. console.Debug("Post.Save: fragment %s is not unique, trying to resolve", p.Fragment)
  238. var resolved bool
  239. for i := 2; i <= 100; i++ {
  240. fragment := fmt.Sprintf("%s-%d", p.Fragment, i)
  241. console.Debug("Post.Save: try fragment '%s'", fragment)
  242. _, err = Posts.LoadFragment(fragment)
  243. if err == nil {
  244. continue
  245. }
  246. p.Fragment = fragment
  247. resolved = true
  248. break
  249. }
  250. if !resolved {
  251. return fmt.Errorf("failed to generate a unique URL fragment for '%s' after 100 attempts", p.Fragment)
  252. }
  253. }
  254. }
  255. // Cache the post thumbnail from the body.
  256. if thumbnail, ok := p.ExtractThumbnail(); ok {
  257. p.Thumbnail = thumbnail
  258. }
  259. // Empty tags list.
  260. if len(p.Tags) == 1 && p.Tags[0].Tag == "" {
  261. p.Tags = []TaggedPost{}
  262. }
  263. // TODO: tag relationships. For now just delete and re-add them all.
  264. if p.ID != 0 {
  265. DB.Where("post_id = ?", p.ID).Delete(TaggedPost{})
  266. }
  267. // Dedupe tags.
  268. p.fixTags()
  269. // Save the post.
  270. if DB.NewRecord(p) {
  271. return DB.Create(&p).Error
  272. }
  273. return DB.Save(&p).Error
  274. }
  275. // ParseForm populates a Post from an HTTP form.
  276. func (p *Post) ParseForm(form *forms.Data) {
  277. p.Title = form.Get("title")
  278. p.Fragment = form.Get("fragment")
  279. p.ContentType = form.Get("content-type")
  280. p.Body = form.Get("body")
  281. p.Privacy = form.Get("privacy")
  282. p.Sticky = form.GetBool("sticky")
  283. p.EnableComments = form.GetBool("enable-comments")
  284. // Parse the tags array. This replaces the post.Tags with an empty TaggedPost
  285. // list containing only the string Tag values. The IDs and DB side will be
  286. // patched up when the post gets saved.
  287. p.Tags = []TaggedPost{}
  288. tags := strings.Split(form.Get("tags"), ",")
  289. for _, tag := range tags {
  290. tag = strings.TrimSpace(tag)
  291. if len(tag) == 0 {
  292. continue
  293. }
  294. p.Tags = append(p.Tags, TaggedPost{
  295. Tag: tag,
  296. })
  297. }
  298. }
  299. // ExtractThumbnail searches and returns a thumbnail image to represent the post.
  300. // It will be the first image embedded in the post body, or nothing.
  301. func (p Post) ExtractThumbnail() (string, bool) {
  302. result := ThumbnailImageRegexp.FindStringSubmatch(p.Body)
  303. if len(result) < 2 {
  304. return "", false
  305. }
  306. return result[1], true
  307. }
  308. // TagsString turns the post tags into a comma separated string.
  309. func (p Post) TagsString() string {
  310. console.Error("TagsString: %+v", p.Tags)
  311. var tags = make([]string, len(p.Tags))
  312. for i, tag := range p.Tags {
  313. tags[i] = tag.Tag
  314. }
  315. return strings.Join(tags, ", ")
  316. }
  317. // fixTags is a pre-Save function to fix up the Tags relationships.
  318. // It checks that each tag has an ID, and if it doesn't have an ID yet, removes
  319. // it if a duplicate tag does exist that has an ID.
  320. func (p *Post) fixTags() {
  321. // De-duplicate tag values.
  322. var dedupe = map[string]interface{}{}
  323. var finalTags []TaggedPost
  324. for _, tag := range p.Tags {
  325. if _, ok := dedupe[tag.Tag]; !ok {
  326. finalTags = append(finalTags, tag)
  327. dedupe[tag.Tag] = nil
  328. }
  329. }
  330. p.Tags = finalTags
  331. }