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:
2026-10-26 22:22:31 +01:00
parent 7fc07d8822
commit 08ab15338c
3 changed files with 378 additions and 0 deletions

View 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
}

View File

@@ -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,

165
record_events_test.go Normal file
View File

@@ -0,0 +1,165 @@
package journal_test
import (
"context"
"fmt"
"net/http"
"sync"
"testing"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
)
// savedEvent is one RecordSaved a test listener heard, with the URL the
// subscriber read back through signpost.
type savedEvent struct {
ev pact.RecordSaved
key string
url string
err error
}
// recordListener collects the record events of an application.
type recordListener struct {
mu sync.Mutex
saved []savedEvent
deleted []pact.RecordDeleted
}
func (l *recordListener) savedEvents() []savedEvent {
l.mu.Lock()
defer l.mu.Unlock()
return append([]savedEvent(nil), l.saved...)
}
func (l *recordListener) deletedEvents() []pact.RecordDeleted {
l.mu.Lock()
defer l.mu.Unlock()
return append([]pact.RecordDeleted(nil), l.deleted...)
}
func (l *recordListener) reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.saved = nil
l.deleted = nil
}
// listenRecords registers a subscriber the way a shared plugin would: on
// RecordSaved it maps the event's model to its content type and describes
// the record, reading the URL of the first locale.
func listenRecords(t *testing.T, env *adminEnv) *recordListener {
t.Helper()
reg, ok := env.app.Lookup[*signpost.Registry]()
if !ok || reg == nil {
t.Fatal("signpost registry is not published")
}
l := &recordListener{}
env.app.Events.Listen("test.records", func(ctx context.Context, ev *pact.RecordSaved) error {
got := savedEvent{ev: *ev}
if typ, ok := reg.ContentTypeForModel(ev.Model); ok {
got.key = typ.Key
rec, err := reg.Describe(ctx, typ.Key, ev.ID, "")
got.err = err
if rec != nil && len(rec.Locales) > 0 {
got.url = rec.Locales[0].URL
}
}
l.mu.Lock()
l.saved = append(l.saved, got)
l.mu.Unlock()
return nil
})
env.app.Events.Listen("test.records", func(ctx context.Context, ev *pact.RecordDeleted) error {
l.mu.Lock()
l.deleted = append(l.deleted, *ev)
l.mu.Unlock()
return nil
})
return l
}
// findChange returns the change of attribute in locale.
func findChange(changes []pact.RecordChange, attribute, locale string) (pact.RecordChange, bool) {
for _, c := range changes {
if c.Attribute == attribute && c.Locale == locale {
return c, true
}
}
return pact.RecordChange{}, false
}
// TestRecordSavedDescribesNewURL is the D-92/D-93 tracer: an admin slug
// change on a Journal post reaches a subscriber after commit, and the
// subscriber reads the new URL through the post's content descriptor. A save
// that fails validation fires nothing.
func TestRecordSavedDescribesNewURL(t *testing.T) {
env := newPostsEnv(t)
token := env.admin(t,
controllers.PermissionAccessPosts,
controllers.PermissionAccessPublish,
controllers.PermissionAccessOtherPosts,
)
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
"title": map[string]any{"en": "Tracer", "pl": "Tropiciel"},
"slug": map[string]any{"en": "tracer-old", "pl": "tropiciel-stary"},
"content": map[string]any{"en": "<p>Body</p>"},
"excerpt": map[string]any{"en": "Excerpt"},
})
id := adminCreatedID(t, rec)
l := listenRecords(t, env)
path := adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id))
env.expect(t, http.StatusOK, http.MethodPut, path, token, map[string]any{
"title": map[string]any{"en": "Tracer", "pl": "Tropiciel"},
"slug": map[string]any{"en": "tracer-new", "pl": "tropiciel-nowy"},
"content": map[string]any{"en": "<p>Body</p>"},
"excerpt": map[string]any{"en": "Excerpt"},
})
got := l.savedEvents()
if len(got) != 1 {
t.Fatalf("RecordSaved events = %d, want 1: %+v", len(got), got)
}
ev := got[0]
if ev.ev.Controller != controllers.PostsControllerID {
t.Fatalf("Controller = %q, want %q", ev.ev.Controller, controllers.PostsControllerID)
}
if ev.ev.Model != (models.Post{}).MorphName() {
t.Fatalf("Model = %q, want the Post morph name", ev.ev.Model)
}
if ev.ev.ID != fmt.Sprint(id) || ev.ev.Created {
t.Fatalf("ID = %q Created = %v, want %d and false", ev.ev.ID, ev.ev.Created, id)
}
slug, ok := findChange(ev.ev.Changes, "slug", "")
if !ok || slug.Old != "tracer-old" || slug.New != "tracer-new" {
t.Fatalf("default slug change = %+v (found %v), want tracer-old -> tracer-new; changes %+v", slug, ok, ev.ev.Changes)
}
plSlug, ok := findChange(ev.ev.Changes, "slug", "pl")
if !ok || plSlug.Old != "tropiciel-stary" || plSlug.New != "tropiciel-nowy" {
t.Fatalf("pl slug change = %+v (found %v), want tropiciel-stary -> tropiciel-nowy; changes %+v", plSlug, ok, ev.ev.Changes)
}
if _, ok := findChange(ev.ev.Changes, "title", ""); ok {
t.Fatalf("unchanged title reported: %+v", ev.ev.Changes)
}
if ev.key != classes.MenuTypePost || ev.err != nil {
t.Fatalf("content type = %q err = %v, want %q", ev.key, ev.err, classes.MenuTypePost)
}
if ev.url != "/blog/tracer-new" {
t.Fatalf("described URL = %q, want /blog/tracer-new", ev.url)
}
l.reset()
bad := env.call(t, http.MethodPut, path, token, map[string]any{
"slug": map[string]any{"en": ""},
})
if bad.Code != http.StatusUnprocessableEntity {
t.Fatalf("invalid save = %d, want 422: %s", bad.Code, bad.Body.String())
}
if got := l.savedEvents(); len(got) != 0 {
t.Fatalf("failed save fired %d events: %+v", len(got), got)
}
}