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 }