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:
Jakub Zych
2026-11-04 10:04:27 +01:00
parent faaa9b0f55
commit 5210b64e0f
8 changed files with 746 additions and 0 deletions

217
classes/heading_fix.go Normal file
View 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
}