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 }