Files
sm-journal-plugin/classes/content_descriptors.go
Jakub Zych 08ab15338c 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
2026-10-26 22:22:31 +01:00

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
}