feat: journal:fix-headings and the settings API
- classes.FixStoredHeadings converts stored H1 in post content (every
locale) and category pillar text, in one transaction with a RecordSaved
and a site cache purge per changed record
- journal:fix-headings, a dry run unless --apply, refuses with exit code 1
while the H1 setting is off
- GET/PUT /_journal/api/v1/settings and POST /fix-headings {dry_run} for
golem15.journal.manage_settings; 409 with the refusal while the setting
is off
This commit is contained in:
217
classes/heading_fix.go
Normal file
217
classes/heading_fix.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// H1ConversionOffText is the refusal of journal:fix-headings and of
|
||||
// POST /_journal/api/v1/fix-headings while the H1 setting is off (UI-SPEC
|
||||
// C27).
|
||||
const H1ConversionOffText = "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings."
|
||||
|
||||
// ErrH1ConversionOff is returned by FixStoredHeadings callers that check
|
||||
// the setting; its text is H1ConversionOffText.
|
||||
var ErrH1ConversionOff = errors.New(H1ConversionOffText)
|
||||
|
||||
// headingFixBatch is how many posts one query of FixStoredHeadings reads.
|
||||
const headingFixBatch = 200
|
||||
|
||||
// HeadingFix counts what FixStoredHeadings converted, or would convert:
|
||||
// the posts and categories with at least one H1 and the H1 headings in
|
||||
// them (default and translated post content, category pillar text).
|
||||
type HeadingFix struct {
|
||||
Posts int `json:"posts"`
|
||||
Categories int `json:"categories"`
|
||||
Headings int `json:"headings"`
|
||||
}
|
||||
|
||||
// FixStoredHeadings converts H1 to H2 in stored Journal rich text: post
|
||||
// content and content_html in the default locale and in every translation,
|
||||
// and category pillar text. Without apply it only counts. With apply it
|
||||
// writes in one transaction, fires a RecordSaved per changed post and
|
||||
// category after the commit (Controller empty) and queues the host site
|
||||
// cache purge for each. It does not read the H1 setting; callers refuse
|
||||
// while it is off (ErrH1ConversionOff). app may be nil (no events, no
|
||||
// purge).
|
||||
func FixStoredHeadings(ctx context.Context, app *backpack.App, db *gorm.DB, apply bool) (HeadingFix, error) {
|
||||
if db == nil {
|
||||
return HeadingFix{}, errors.New("journal: database is not configured")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if !apply {
|
||||
return fixHeadings(ctx, nil, db.WithContext(ctx), false)
|
||||
}
|
||||
var out HeadingFix
|
||||
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
var err error
|
||||
out, err = fixHeadings(ctx, app, tx, true)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return HeadingFix{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func fixHeadings(ctx context.Context, app *backpack.App, db *gorm.DB, apply bool) (HeadingFix, error) {
|
||||
var out HeadingFix
|
||||
locale, _ := towel.Locale(ctx)
|
||||
purger := cooler.SiteCacheFromApp(app)
|
||||
morph := models.Post{}.MorphName()
|
||||
|
||||
var batch []models.Post
|
||||
res := db.Model(&models.Post{}).Select("id", "slug", "content", "content_html").
|
||||
FindInBatches(&batch, headingFixBatch, func(tx *gorm.DB, _ int) error {
|
||||
ids := make([]string, 0, len(batch))
|
||||
for _, p := range batch {
|
||||
ids = append(ids, strconv.FormatUint(uint64(p.ID), 10))
|
||||
}
|
||||
var attrs []translatemodels.Attribute
|
||||
if err := db.Model(&translatemodels.Attribute{}).
|
||||
Where("model_type = ? AND model_id IN ?", morph, ids).
|
||||
Order("locale ASC").Find(&attrs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
byPost := map[string][]translatemodels.Attribute{}
|
||||
for _, a := range attrs {
|
||||
if a.ModelID != nil {
|
||||
byPost[*a.ModelID] = append(byPost[*a.ModelID], a)
|
||||
}
|
||||
}
|
||||
for i := range batch {
|
||||
post := &batch[i]
|
||||
id := strconv.FormatUint(uint64(post.ID), 10)
|
||||
n, changes, err := fixPost(ctx, db, post, byPost[id], apply)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
out.Posts++
|
||||
out.Headings += n
|
||||
if apply {
|
||||
cabana.EmitRecordSaved(ctx, app, db, pact.RecordSaved{Model: morph, ID: id, Locale: locale, Changes: changes})
|
||||
cooler.PurgeSiteCacheAfterCommit(ctx, db, purger, "post", post.Slug)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if res.Error != nil {
|
||||
return HeadingFix{}, res.Error
|
||||
}
|
||||
|
||||
var cats []models.Category
|
||||
if err := db.Model(&models.Category{}).Select("id", "slug", "content").
|
||||
Where("content IS NOT NULL").Order("id ASC").Find(&cats).Error; err != nil {
|
||||
return HeadingFix{}, err
|
||||
}
|
||||
catMorph := models.Category{}.MorphName()
|
||||
for _, c := range cats {
|
||||
converted, n := ConvertH1(*c.Content)
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
out.Categories++
|
||||
out.Headings += n
|
||||
if !apply {
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&models.Category{}).Where("id = ?", c.ID).Update("content", converted).Error; err != nil {
|
||||
return HeadingFix{}, err
|
||||
}
|
||||
id := strconv.FormatUint(uint64(c.ID), 10)
|
||||
cabana.EmitRecordSaved(ctx, app, db, pact.RecordSaved{Model: catMorph, ID: id, Locale: locale, Changes: []pact.RecordChange{
|
||||
{Attribute: "content", Old: *c.Content, New: converted},
|
||||
}})
|
||||
cooler.PurgeSiteCacheAfterCommit(ctx, db, purger, "category", c.Slug)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fixPost converts one post's host content and its translations (attrs,
|
||||
// the post's translate rows) and, with apply, writes what changed. It
|
||||
// returns the number of headings and the record changes.
|
||||
func fixPost(ctx context.Context, db *gorm.DB, post *models.Post, attrs []translatemodels.Attribute, apply bool) (int, []pact.RecordChange, error) {
|
||||
total := 0
|
||||
var changes []pact.RecordChange
|
||||
content, html, n := ConvertContentH1(post.Content, post.ContentHTML)
|
||||
if n > 0 {
|
||||
total += n
|
||||
if content != post.Content {
|
||||
changes = append(changes, pact.RecordChange{Attribute: "content", Old: post.Content, New: content})
|
||||
}
|
||||
if html != post.ContentHTML {
|
||||
changes = append(changes, pact.RecordChange{Attribute: "content_html", Old: post.ContentHTML, New: html})
|
||||
}
|
||||
if apply {
|
||||
if err := db.Model(&models.Post{}).Where("id = ?", post.ID).
|
||||
Updates(map[string]any{"content": content, "content_html": html}).Error; err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, a := range attrs {
|
||||
if a.AttributeData == nil {
|
||||
continue
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(*a.AttributeData), &data); err != nil {
|
||||
continue
|
||||
}
|
||||
before := map[string]any{"content": data["content"], "content_html": data["content_html"]}
|
||||
n := ConvertTranslationH1(data)
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
total += n
|
||||
for _, field := range []string{"content", "content_html"} {
|
||||
if data[field] != before[field] {
|
||||
changes = append(changes, pact.RecordChange{Attribute: field, Locale: a.Locale, Old: before[field], New: data[field]})
|
||||
}
|
||||
}
|
||||
if !apply {
|
||||
continue
|
||||
}
|
||||
// The row is rewritten as it is, the other attributes kept: a
|
||||
// translation in a locale disabled since keeps its content too.
|
||||
raw, err := encodeAttributes(data)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if err := db.Model(&translatemodels.Attribute{}).Where("id = ?", a.ID).
|
||||
Update("attribute_data", raw).Error; err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
return total, changes, nil
|
||||
}
|
||||
|
||||
// encodeAttributes encodes a translate row's attribute map as the translate
|
||||
// plugin does: JSON without HTML escaping.
|
||||
func encodeAttributes(data map[string]any) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(buf.String()), nil
|
||||
}
|
||||
Reference in New Issue
Block a user