feat: post content descriptor
- 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
This commit is contained in:
212
classes/content_descriptors.go
Normal file
212
classes/content_descriptors.go
Normal file
@@ -0,0 +1,212 @@
|
||||
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
|
||||
}
|
||||
@@ -124,6 +124,7 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
|
||||
Placeholders: append([]string(nil), postPlaceholders...),
|
||||
References: m.postReferences,
|
||||
Resolve: m.resolvePost,
|
||||
Content: m.postContent(),
|
||||
},
|
||||
{
|
||||
Key: MenuTypeAllPosts,
|
||||
|
||||
Reference in New Issue
Block a user