Files
sm-journal-plugin/classes/content_facts.go
Jakub Zych e8e77a154c feat: descriptor dates, parents, authors, media and outline
- posts describe PublishedAt, their categories by name as Parents, the
  author (a journal-author reference while author pages are on), the
  featured and body images, YouTube embeds, the heading outline and
  TopHeading (2 while the H1 setting is on, else 0)
- categories describe their parent, the pillar text's outline and
  TopHeading; kinds article/collection and the journal_ fixing tools
- golem15.journal.author_pages (AuthorPagesKey, default true)
2026-11-04 10:16:15 +01:00

212 lines
6.0 KiB
Go

package classes
import (
"context"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
xhtml "golang.org/x/net/html"
"gorm.io/gorm"
)
// The MCP tools a Journal content descriptor names (pact.ContentDescriptor
// Tools, D-96): Journal fixes its own content.
const (
toolUpdatePost = "journal_update_post"
toolFixHeadings = "journal_fix_headings"
toolSetImageAlt = "journal_set_image_alt"
)
// contentFacts is what one locale's body says about itself: its images
// (src, alt, decorative, size attributes), its YouTube embeds and its
// heading outline, each in document order.
type contentFacts struct {
images []pact.ContentImage
videos []pact.ContentVideo
headings []pact.ContentHeading
}
// parseContentFacts reads html once with the x/net/html tokenizer. An img
// without src is skipped; an img whose alt attribute is present and empty
// is decorative. A figure with data-embed="youtube" and a data-id is a
// video (the Blog Editor's embed placeholder). A heading's text is its
// text content with white space collapsed; a heading inside a heading is
// read as part of the outer one.
func parseContentFacts(html string) contentFacts {
var out contentFacts
if strings.TrimSpace(html) == "" {
return out
}
z := xhtml.NewTokenizer(strings.NewReader(html))
level := 0
var text strings.Builder
for {
tt := z.Next()
if tt == xhtml.ErrorToken {
break
}
switch tt {
case xhtml.TextToken:
if level > 0 {
text.WriteString(z.Token().Data)
}
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
tok := z.Token()
switch tok.Data {
case "img":
if img, ok := contentImage(tok); ok {
out.images = append(out.images, img)
}
case "figure":
if attr(tok, "data-embed") == "youtube" {
if id := attr(tok, "data-id"); id != "" {
out.videos = append(out.videos, pact.ContentVideo{Provider: "youtube", ID: id})
}
}
case "h1", "h2", "h3", "h4", "h5", "h6":
if level == 0 && tt == xhtml.StartTagToken {
level = int(tok.Data[1] - '0')
text.Reset()
}
}
if level > 0 && tt == xhtml.StartTagToken && !inlineListTags[tok.Data] && !isHeading(tok.Data) {
text.WriteByte(' ')
}
case xhtml.EndTagToken:
tok := z.Token()
if level > 0 && tok.Data == "h"+strconv.Itoa(level) {
out.headings = append(out.headings, pact.ContentHeading{
Level: level,
Text: strings.Join(strings.Fields(text.String()), " "),
})
level = 0
}
}
}
return out
}
func isHeading(name string) bool {
return len(name) == 2 && name[0] == 'h' && name[1] >= '1' && name[1] <= '6'
}
func contentImage(tok xhtml.Token) (pact.ContentImage, bool) {
src := strings.TrimSpace(attr(tok, "src"))
if src == "" {
return pact.ContentImage{}, false
}
img := pact.ContentImage{URL: src}
if alt, has := attrOK(tok, "alt"); has {
img.Alt = strings.TrimSpace(alt)
img.Decorative = img.Alt == ""
}
img.Width, _ = strconv.Atoi(strings.TrimSpace(attr(tok, "width")))
img.Height, _ = strconv.Atoi(strings.TrimSpace(attr(tok, "height")))
if img.Width < 0 {
img.Width = 0
}
if img.Height < 0 {
img.Height = 0
}
return img, true
}
func attr(tok xhtml.Token, key string) string {
v, _ := attrOK(tok, key)
return v
}
func attrOK(tok xhtml.Token, key string) (string, bool) {
for _, a := range tok.Attr {
if a.Namespace == "" && a.Key == key {
return a.Val, true
}
}
return "", false
}
// topHeading is a record's TopHeading: 2 while the H1 setting is on (the
// page title is the H1), 0 (any level) while it is off.
func topHeading(h1ToH2 bool) int {
if h1ToH2 {
return 2
}
return 0
}
// postCategoryRefs maps each post id to its categories as journal-category
// references, by category name, ties by id (the order :category takes its
// first category in).
func postCategoryRefs(ctx context.Context, db *gorm.DB, ids []uint) (map[uint][]pact.ContentRef, error) {
out := map[uint][]pact.ContentRef{}
if len(ids) == 0 {
return out, nil
}
var rows []struct {
PostID uint `gorm:"column:post_id"`
CategoryID uint `gorm:"column:category_id"`
}
err := db.WithContext(ctx).Table("golem15_journal_posts_categories AS pc").
Select("pc.post_id, pc.category_id").
Joins("JOIN golem15_journal_categories c ON c.id = pc.category_id").
Where("pc.post_id IN ?", ids).
Order("pc.post_id ASC, c.name ASC, c.id ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, r := range rows {
out[r.PostID] = append(out[r.PostID], pact.ContentRef{Type: MenuTypeCategory, ID: strconv.FormatUint(uint64(r.CategoryID), 10)})
}
return out, nil
}
// authorRow is the part of a backend user a descriptor shows.
type authorRow struct {
ID uint `gorm:"column:id"`
FirstName string `gorm:"column:first_name"`
LastName string `gorm:"column:last_name"`
Login string `gorm:"column:login"`
AuthorSlug string `gorm:"column:author_slug"`
}
// authorColumns is the only projection descriptors read from backend_users:
// no contact, credential or permission column.
const authorColumns = "id, COALESCE(first_name, '') AS first_name, COALESCE(last_name, '') AS last_name, " +
"login, COALESCE(golem15_bloghub_author_slug, '') AS author_slug"
// name is the author's display name: first and last name, else the login.
func (a authorRow) name() string {
if n := strings.TrimSpace(strings.TrimSpace(a.FirstName) + " " + strings.TrimSpace(a.LastName)); n != "" {
return n
}
return a.Login
}
// slug is the :author value: the author slug, else the login (the author
// API's own lookup order).
func (a authorRow) slug() string {
if a.AuthorSlug != "" {
return a.AuthorSlug
}
return a.Login
}
// loadAuthors reads the live backend users with the given ids.
func loadAuthors(ctx context.Context, db *gorm.DB, ids []uint) (map[uint]authorRow, error) {
out := map[uint]authorRow{}
if len(ids) == 0 {
return out, nil
}
var rows []authorRow
if err := db.WithContext(ctx).Table("backend_users").Select(authorColumns).
Where("deleted_at IS NULL AND id IN ?", ids).Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
out[r.ID] = r
}
return out, nil
}