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 }