From 5210b64e0fb89e881f36c3e18e9a0f7f75a04fbf Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 10:04:27 +0100 Subject: [PATCH] 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 --- classes/heading_fix.go | 217 ++++++++++++++++++++++++++++++++ console/fix_headings.go | 66 ++++++++++ controllers/api/maintenance.go | 53 ++++++++ controllers/api/settings.go | 219 +++++++++++++++++++++++++++++++++ controllers/posts.go | 3 + headings_test.go | 184 +++++++++++++++++++++++++++ plugin.go | 1 + routes.go | 3 + 8 files changed, 746 insertions(+) create mode 100644 classes/heading_fix.go create mode 100644 console/fix_headings.go create mode 100644 controllers/api/maintenance.go create mode 100644 controllers/api/settings.go diff --git a/classes/heading_fix.go b/classes/heading_fix.go new file mode 100644 index 0000000..64a3184 --- /dev/null +++ b/classes/heading_fix.go @@ -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 +} diff --git a/console/fix_headings.go b/console/fix_headings.go new file mode 100644 index 0000000..943a295 --- /dev/null +++ b/console/fix_headings.go @@ -0,0 +1,66 @@ +package console + +import ( + "context" + "errors" + "fmt" + + "git.golem15.com/golem15/sm-journal-plugin/classes" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bonfire" +) + +// FixHeadings is journal:fix-headings: it converts H1 to H2 in stored +// Journal rich text (post content in every locale and category pillar text) +// while the Journal H1 setting is on. It is a dry run unless --apply is +// given; with the setting off it refuses with exit code 1. +func FixHeadings(app *backpack.App) bonfire.Command { + return bonfire.Command{ + Name: "journal:fix-headings", + Description: "Convert H1 headings to H2 in stored post and category content", + Long: `Finds every H1 heading in stored Journal content: post content and +content_html in the default locale and in every translation, and the pillar +text of every category. Switching the Journal H1 setting (Settings › Journal, +Headings and links) on never rewrites stored content; this command does. + +By default it only counts ("Would convert N H1 headings in N posts and N +categories. Run with --apply to save."). With --apply it converts them in +one transaction, fires a record event per changed post and category (so an +SEO or search plugin hears about it) and queues the site cache purge. + +While the H1 setting is off the command refuses and exits with code 1. The +MCP tool journal_fix_headings runs the same conversion.`, + Flags: []bonfire.Flag{ + {Name: "apply", Description: "Save the converted content (default: dry run)", Bare: true}, + {Name: "dry-run", Description: "Only count the H1 headings (the default)", Bare: true}, + }, + Examples: []bonfire.Example{ + {Command: "journal:fix-headings", Description: "Count the H1 headings that would be converted"}, + {Command: "journal:fix-headings --apply", Description: "Convert them and save"}, + }, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + db := mustDB(app) + if db == nil { + return errors.New("journal: database is not configured") + } + if !classes.EffectiveH1ToH2(ctx, db, appConfig(app)) { + out.Error(classes.H1ConversionOffText) + return bonfire.Reported(classes.ErrH1ConversionOff) + } + apply := flagOn(in, "apply") && !flagOn(in, "dry-run") + res, err := classes.FixStoredHeadings(ctx, app, db, apply) + if err != nil { + return err + } + switch { + case res.Headings == 0: + out.Info("No H1 headings found in post or category content.") + case apply: + out.Success(fmt.Sprintf("Converted %d H1 headings in %d posts and %d categories.", res.Headings, res.Posts, res.Categories)) + default: + out.Info(fmt.Sprintf("Would convert %d H1 headings in %d posts and %d categories. Run with --apply to save.", res.Headings, res.Posts, res.Categories)) + } + return nil + }, + } +} diff --git a/controllers/api/maintenance.go b/controllers/api/maintenance.go new file mode 100644 index 0000000..147377f --- /dev/null +++ b/controllers/api/maintenance.go @@ -0,0 +1,53 @@ +package api + +import ( + "net/http" + + "git.golem15.com/golem15/sm-journal-plugin/classes" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/wire" +) + +// FixHeadings is POST /_journal/api/v1/fix-headings {dry_run}: the +// journal:fix-headings conversion of stored H1 headings (posts in every +// locale and category pillar text), for golem15.journal.manage_settings. It +// is a dry run unless dry_run is false, and answers {posts, categories, +// headings, dry_run}. While the H1 setting is off it refuses with 409 and +// the refusal phrase. MCP journal_fix_headings. +func FixHeadings(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r, pr, ok := requireBackendPrincipal(app, w, r) + if !ok || !requireManageSettings(w, pr) { + return + } + db, err := gdb(app) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "Internal server error") + return + } + body, err := readJSONObject(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, errValidationFailed) + return + } + dry := true + if v, has := body["dry_run"]; has && v != nil { + dry = asBool(v) + } + if !classes.EffectiveH1ToH2(r.Context(), db, appConfig(app)) { + writeAPIError(w, http.StatusConflict, classes.H1ConversionOffText) + return + } + res, err := classes.FixStoredHeadings(r.Context(), app, db, !dry) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "Internal server error") + return + } + wire.WriteJSON(w, http.StatusOK, map[string]any{ + "posts": res.Posts, + "categories": res.Categories, + "headings": res.Headings, + "dry_run": dry, + }) + } +} diff --git a/controllers/api/settings.go b/controllers/api/settings.go new file mode 100644 index 0000000..3df2701 --- /dev/null +++ b/controllers/api/settings.go @@ -0,0 +1,219 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "slices" + "strings" + + "git.golem15.com/golem15/sm-journal-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) + +// permManageSettings guards the settings and maintenance routes. +const permManageSettings = "golem15.journal.manage_settings" + +// requireManageSettings answers 403 {"error":"Insufficient permissions"} +// without golem15.journal.manage_settings. +func requireManageSettings(w http.ResponseWriter, pr *bouncer.Principal) bool { + if !cabana.Allows(pr, []string{permManageSettings}) { + writeAPIError(w, http.StatusForbidden, errInsufficientPerms) + return false + } + return true +} + +// serializeSettings is the settings answer: every fillable column by name. +func serializeSettings(s models.Settings) map[string]any { + return map[string]any{ + "show_all_posts": s.ShowAllPosts, + "use_rich_editor": s.UseRichEditor, + "search_use_typesense": s.SearchUseTypesense, + "search_title_weight": s.SearchTitleWeight, + "search_excerpt_weight": s.SearchExcerptWeight, + "search_content_weight": s.SearchContentWeight, + "rss_enabled": s.RSSEnabled, + "rss_include_content": s.RSSIncludeContent, + "rss_title": s.RSSTitle, + "rss_description": s.RSSDescription, + "rss_language": s.RSSLanguage, + "rss_copyright": s.RSSCopyright, + "rss_image_url": s.RSSImageURL, + "rss_posts_per_feed": s.RSSPostsPerFeed, + "content_h1_to_h2": s.ContentH1ToH2, + "generic_anchors": s.GenericAnchors, + } +} + +// loadSettings reads the settings row (ID 1); without one it is the +// migration's defaults, not yet stored. +func loadSettings(db *gorm.DB) (models.Settings, error) { + var s models.Settings + err := db.First(&s, 1).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return models.Settings{ + ID: 1, ShowAllPosts: true, SearchTitleWeight: 5, SearchExcerptWeight: 3, SearchContentWeight: 1, + RSSEnabled: true, RSSLanguage: "en-us", RSSPostsPerFeed: 20, ContentH1ToH2: true, + }, nil + } + return s, err +} + +// ShowSettings is GET /_journal/api/v1/settings: the Journal settings row, +// for golem15.journal.manage_settings (MCP journal_get_settings). +func ShowSettings(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r, pr, ok := requireBackendPrincipal(app, w, r) + if !ok || !requireManageSettings(w, pr) { + return + } + db, err := gdb(app) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "Internal server error") + return + } + s, err := loadSettings(db.WithContext(r.Context())) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "Internal server error") + return + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializeSettings(s)}) + } +} + +// UpdateSettings is PUT /_journal/api/v1/settings: it writes the submitted +// fillable columns (models.Settings.Fillable) after checking them against +// the model's Rules, and answers the row. Other keys are ignored. Changing +// content_h1_to_h2 never rewrites stored content (D-50). MCP +// journal_update_settings. +func UpdateSettings(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r, pr, ok := requireBackendPrincipal(app, w, r) + if !ok || !requireManageSettings(w, pr) { + return + } + db, err := gdb(app) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "Internal server error") + return + } + body, err := readJSONObject(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, errValidationFailed) + return + } + fillable := models.Settings{}.Fillable() + values := map[string]any{} + for key, v := range body { + if slices.Contains(fillable, key) { + values[key] = plainJSON(v) + } + } + var tr *phrasebook.Translator + if app != nil { + tr, _ = app.Lookup[*phrasebook.Translator]() + } + var saved models.Settings + var verrs map[string][]string + err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { + s, err := loadSettings(tx) + if err != nil { + return err + } + rules := map[string]string{} + for key, rule := range s.Rules() { + if _, has := values[key]; has { + rules[key], verrs = settingsRule(key, rule, values[key], verrs) + } + } + if len(verrs) > 0 { + return nil + } + if verrs, err = lagoon.Validate(ctx, tx, &s, rules, values, tr); err != nil || len(verrs) > 0 { + return err + } + if err := lagoon.Fill(&s, fillable, values, true); err != nil { + var typeErr *lagoon.FillTypeError + if errors.As(err, &typeErr) { + verrs = map[string][]string{typeErr.Key: {"The " + typeErr.Key + " field is invalid."}} + return nil + } + return err + } + s.ID = 1 + if err := tx.Save(&s).Error; err != nil { + return err + } + saved = s + return nil + }) + if err != nil { + pluginLog.ErrorContext(r.Context(), "journal api: settings update failed", "error", err) + writeAPIError(w, http.StatusInternalServerError, "Internal server error") + return + } + if len(verrs) > 0 { + writeValidation(w, verrs) + return + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializeSettings(saved)}) + } +} + +// plainJSON turns a json.Number (readJSONObject decodes with UseNumber) +// into an int64, or a float64 when it is not an integer. +func plainJSON(v any) any { + n, ok := v.(json.Number) + if !ok { + return v + } + if i, err := n.Int64(); err == nil { + return i + } + if f, err := n.Float64(); err == nil { + return f + } + return v +} + +// settingsRule checks the string and url tokens of a Settings rule, which +// lagoon.Validate does not know, and returns the rule without them for +// lagoon.Validate. A failed check is added to verrs. +func settingsRule(key, rule string, value any, verrs map[string][]string) (string, map[string][]string) { + var kept []string + for _, tok := range strings.Split(rule, "|") { + switch tok { + case "string": + if _, ok := value.(string); !ok && value != nil { + verrs = addSettingsError(verrs, key, "The "+key+" must be a string.") + } + case "url": + if text, _ := value.(string); text != "" { + u, err := url.Parse(text) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + verrs = addSettingsError(verrs, key, "The "+key+" format is invalid.") + } + } + default: + kept = append(kept, tok) + } + } + return strings.Join(kept, "|"), verrs +} + +func addSettingsError(verrs map[string][]string, key, msg string) map[string][]string { + if verrs == nil { + verrs = map[string][]string{} + } + verrs[key] = append(verrs[key], msg) + return verrs +} diff --git a/controllers/posts.go b/controllers/posts.go index 115f983..a5bae9c 100644 --- a/controllers/posts.go +++ b/controllers/posts.go @@ -30,6 +30,9 @@ const ( // PermissionAccessImportExport gates journal:export-posts / import-posts // toolbar actions (D-13). PermissionAccessImportExport = "golem15.journal.access_import_export" + // PermissionManageSettings is the Journal settings permission; the + // /_journal/api/v1 settings and fix-headings routes require it. + PermissionManageSettings = "golem15.journal.manage_settings" ) type postsAdminController struct { diff --git a/headings_test.go b/headings_test.go index 2a71a9b..ce8e3e7 100644 --- a/headings_test.go +++ b/headings_test.go @@ -2,6 +2,7 @@ package journal_test import ( "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -341,3 +342,186 @@ func TestHeadingConversion(t *testing.T) { setH1ToH2(t, env, true) }) } + +const h1Refusal = "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings." + +// seedStoredH1 stores, with the setting off, a post whose default and +// Polish content hold one H1 each and a category whose pillar text holds +// one H1, and switches the setting back on. It returns the post and the +// category id. +func seedStoredH1(t *testing.T, env *adminEnv, slug string) (uint, uint) { + t.Helper() + setH1ToH2(t, env, false) + token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessCategories) + post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", token, map[string]any{ + "title": "Stored " + slug, "slug": slug, "content": "

A

x

", + "translations": map[string]any{"pl": map[string]any{"title": "Zapisany " + slug, "content": "

B

"}}, + })) + cat := adminCreatedID(t, env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{ + "name": map[string]any{"en": "Stored " + slug, "pl": "Zapisana " + slug}, + "slug": map[string]any{"en": slug + "-cat", "pl": slug + "-kat"}, + "content": "

C

", + })) + setH1ToH2(t, env, true) + return post, cat +} + +func categoryContent(t *testing.T, env *adminEnv, id uint) string { + t.Helper() + var c models.Category + if err := env.gdb.First(&c, id).Error; err != nil { + t.Fatal(err) + } + if c.Content == nil { + return "" + } + return *c.Content +} + +// TestFixHeadingsCommand: journal:fix-headings is a dry run by default, +// --apply converts default and translated post content and category pillar +// text and fires RecordSaved per changed record, a second run finds +// nothing, and with the setting off it refuses with exit code 1 (D-50, +// UI-SPEC C27 console copy, P33-54). +func TestFixHeadingsCommand(t *testing.T) { + env := newPostsEnv(t) + post, cat := seedStoredH1(t, env, "fix-cli") + run := func(t *testing.T, flags cmdInput) (string, error) { + t.Helper() + var buf strings.Builder + out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf) + err := console.FixHeadings(env.app).Run(t.Context(), flags, out) + return buf.String(), err + } + + text, err := run(t, cmdInput{}) + if err != nil || !strings.Contains(text, "Would convert 3 H1 headings in 1 posts and 1 categories. Run with --apply to save.") { + t.Fatalf("dry run = %q, %v", text, err) + } + if p := storedPost(t, env, post); p.ContentHTML != "

A

x

" || categoryContent(t, env, cat) != "

C

" { + t.Fatalf("dry run wrote: %q / %q", p.ContentHTML, categoryContent(t, env, cat)) + } + + records := listenRecords(t, env) + text, err = run(t, cmdInput{"apply": "true"}) + if err != nil || !strings.Contains(text, "Converted 3 H1 headings in 1 posts and 1 categories.") { + t.Fatalf("apply = %q, %v", text, err) + } + p := storedPost(t, env, post) + if p.Content != "

A

x

" || p.ContentHTML != "

A

x

" { + t.Fatalf("applied post = %q / %q", p.Content, p.ContentHTML) + } + if pl := translatedAttrs(t, env, post, "pl"); pl["content"] != "

B

" || strings.Contains(pl["content_html"].(string), "