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,
|
||||
|
||||
165
record_events_test.go
Normal file
165
record_events_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user