- 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)
212 lines
6.0 KiB
Go
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
|
|
}
|