From 08ab15338c04e79a358792f459f5af97100435e8 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 26 Oct 2026 22:22:31 +0100 Subject: [PATCH] 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 --- classes/content_descriptors.go | 212 +++++++++++++++++++++++++++++++++ classes/menu_item_types.go | 1 + record_events_test.go | 165 +++++++++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 classes/content_descriptors.go create mode 100644 record_events_test.go diff --git a/classes/content_descriptors.go b/classes/content_descriptors.go new file mode 100644 index 0000000..11b1234 --- /dev/null +++ b/classes/content_descriptors.go @@ -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 +} diff --git a/classes/menu_item_types.go b/classes/menu_item_types.go index a1a7672..4d95694 100644 --- a/classes/menu_item_types.go +++ b/classes/menu_item_types.go @@ -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, diff --git a/record_events_test.go b/record_events_test.go new file mode 100644 index 0000000..7b73fca --- /dev/null +++ b/record_events_test.go @@ -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": "

Body

"}, + "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": "

Body

"}, + "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) + } +}