- journal-post registers a pact.ContentDescriptor (posts controller, Post morph name, Describe with per-locale URL, title, excerpt, image, words) - descriptors read through cabana.TxFromContext inside a save - TestRecordSavedDescribesNewURL: an admin slug change reaches a subscriber after commit and Describe answers the new URL; a failed save fires nothing
213 lines
5.7 KiB
Go
213 lines
5.7 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"html"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// The admin controller IDs whose forms edit the described records. They
|
|
// repeat the controllers package constants, which this package cannot
|
|
// import (controllers imports classes).
|
|
const (
|
|
postsControllerID = "golem15.journal.posts"
|
|
categoriesControllerID = "golem15.journal.categories"
|
|
tagsControllerID = "golem15.journal.tags"
|
|
)
|
|
|
|
// contentDB is the database a content descriptor reads: the write
|
|
// transaction in ctx when there is one (cabana.TxFromContext), so code
|
|
// running inside a save sees what the save wrote, else the application's.
|
|
func (m menuTypes) contentDB(ctx context.Context) (*gorm.DB, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if tx, ok := cabana.TxFromContext(ctx); ok {
|
|
return tx.Session(&gorm.Session{NewDB: true, Context: ctx}), nil
|
|
}
|
|
return m.db(ctx)
|
|
}
|
|
|
|
// postContent is the D-93 descriptor of journal-post: posts are edited by
|
|
// the posts controller and carry the Post morph name.
|
|
func (m menuTypes) postContent() *pact.ContentDescriptor {
|
|
return &pact.ContentDescriptor{
|
|
Controller: postsControllerID,
|
|
Model: models.Post{}.MorphName(),
|
|
Describe: m.describePost,
|
|
}
|
|
}
|
|
|
|
// describePost describes one post, drafts included: Published tells
|
|
// whether visitors see it.
|
|
func (m menuTypes) describePost(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
|
id, ok := menuReferenceID(in.ID)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
db, err := m.contentDB(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var posts []models.Post
|
|
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(posts) == 0 {
|
|
return nil, nil
|
|
}
|
|
records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &records[0], nil
|
|
}
|
|
|
|
// describePosts describes posts in the given order. Each locale's URL is
|
|
// the alternate resolvePost builds; Title is the translated title,
|
|
// Description the excerpt as plain text, Image the first featured image and
|
|
// Words the body's word count, each translated with the default value as
|
|
// fallback.
|
|
func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, pattern, unprefixed string) ([]pact.ContentRecord, error) {
|
|
in := pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}
|
|
items, err := resolvePostItems(ctx, db, posts, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
locales, err := loadMenuLocales(ctx, db, unprefixed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]uint, 0, len(posts))
|
|
for _, p := range posts {
|
|
ids = append(ids, p.ID)
|
|
}
|
|
tr, err := loadMenuTranslations(ctx, db, models.Post{}.MorphName(), ids, locales)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
images, err := firstImages(ctx, db, models.Post{}.MorphName(), "featured_images", ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]pact.ContentRecord, 0, len(posts))
|
|
for i := range posts {
|
|
p := &posts[i]
|
|
rec := pact.ContentRecord{
|
|
ID: strconv.FormatUint(uint64(p.ID), 10),
|
|
Published: postPublished(p),
|
|
LastMod: copyTime(p.UpdatedAt),
|
|
}
|
|
for _, alt := range items[i].Alternates {
|
|
locale := alt.Locale
|
|
if locale == xDefaultLocale {
|
|
locale = ""
|
|
}
|
|
body := tr.value(locale, p.ID, "content_html", p.ContentHTML)
|
|
if strings.TrimSpace(body) == "" {
|
|
body = tr.value(locale, p.ID, "content", p.Content)
|
|
}
|
|
words := wordCount(plainText(body))
|
|
rec.Locales = append(rec.Locales, pact.ContentLocale{
|
|
Locale: alt.Locale,
|
|
URL: alt.URL,
|
|
Title: alt.Title,
|
|
Description: strings.TrimSpace(plainText(tr.value(locale, p.ID, "excerpt", p.Excerpt))),
|
|
Image: images[p.ID],
|
|
Words: &words,
|
|
})
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// postPublished is publishedPosts for one loaded post.
|
|
func postPublished(p *models.Post) bool {
|
|
return p.Published && p.PublishedAt != nil && p.PublishedAt.Before(time.Now())
|
|
}
|
|
|
|
// firstImages maps each record id to the public URL of its first file in
|
|
// field (sort order, then id).
|
|
func firstImages(ctx context.Context, db *gorm.DB, morph, field string, ids []uint) (map[uint]string, error) {
|
|
out := map[uint]string{}
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
keys := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
keys = append(keys, strconv.FormatUint(uint64(id), 10))
|
|
}
|
|
var files []attach.File
|
|
err := db.WithContext(ctx).
|
|
Where("attachment_type = ? AND field = ? AND attachment_id IN ?", morph, field, keys).
|
|
Order("sort_order ASC, id ASC").
|
|
Find(&files).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range files {
|
|
id, err := strconv.ParseUint(files[i].AttachmentID, 10, 32)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if _, seen := out[uint(id)]; !seen {
|
|
out[uint(id)] = files[i].URL()
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func copyTime(t *time.Time) *time.Time {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
v := *t
|
|
return &v
|
|
}
|
|
|
|
// plainText drops HTML tags and unescapes entities, with whitespace runs
|
|
// collapsed to one space.
|
|
func plainText(s string) string {
|
|
var b strings.Builder
|
|
inTag := false
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '<':
|
|
inTag = true
|
|
b.WriteRune(' ')
|
|
case r == '>':
|
|
inTag = false
|
|
case !inTag:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return strings.Join(strings.Fields(html.UnescapeString(b.String())), " ")
|
|
}
|
|
|
|
// wordCount counts the whitespace-separated words of s.
|
|
func wordCount(s string) int {
|
|
n := 0
|
|
in := false
|
|
for _, r := range s {
|
|
if unicode.IsSpace(r) {
|
|
in = false
|
|
continue
|
|
}
|
|
if !in {
|
|
n++
|
|
in = true
|
|
}
|
|
}
|
|
return n
|
|
}
|