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
}

66
console/fix_headings.go Normal file
View File

@@ -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
},
}
}

View File

@@ -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,
})
}
}

219
controllers/api/settings.go Normal file
View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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": "<h1>A</h1><p>x</p>",
"translations": map[string]any{"pl": map[string]any{"title": "Zapisany " + slug, "content": "<h1>B</h1>"}},
}))
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": "<h1>C</h1>",
}))
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 != "<h1>A</h1><p>x</p>" || categoryContent(t, env, cat) != "<h1>C</h1>" {
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 != "<h2>A</h2><p>x</p>" || p.ContentHTML != "<h2>A</h2><p>x</p>" {
t.Fatalf("applied post = %q / %q", p.Content, p.ContentHTML)
}
if pl := translatedAttrs(t, env, post, "pl"); pl["content"] != "<h2>B</h2>" || strings.Contains(pl["content_html"].(string), "<h1") {
t.Fatalf("applied pl = %#v", pl)
}
if got := categoryContent(t, env, cat); got != "<h2>C</h2>" {
t.Fatalf("applied category = %q", got)
}
seen := map[string]bool{}
for _, ev := range records.savedEvents() {
seen[ev.ev.Model] = true
if ev.ev.Model == p.MorphName() {
if c, ok := findChange(ev.ev.Changes, "content_html", "pl"); !ok || c.New == nil {
t.Fatalf("post event without the pl content_html change: %#v", ev.ev.Changes)
}
}
}
if !seen[p.MorphName()] || !seen[models.Category{}.MorphName()] {
t.Fatalf("RecordSaved models = %v, want the post and the category", seen)
}
if text, err = run(t, cmdInput{"apply": "true"}); err != nil || !strings.Contains(text, "No H1 headings found in post or category content.") {
t.Fatalf("second run = %q, %v", text, err)
}
setH1ToH2(t, env, false)
t.Cleanup(func() { setH1ToH2(t, env, true) })
text, err = run(t, cmdInput{"apply": "true"})
if err == nil || !errors.Is(err, bonfire.ErrReported) || !strings.Contains(text, h1Refusal) {
t.Fatalf("setting off = %q, %v; want the refusal and exit code 1", text, err)
}
}
// TestJournalSettingsTools: GET and PUT /_journal/api/v1/settings and POST
// /fix-headings, as the MCP tools call them, with
// golem15.journal.manage_settings, and 401/403 without (T-33-05).
func TestJournalSettingsTools(t *testing.T) {
env := newPostsEnv(t)
settings := journalAPIPrefix + "/settings"
manager := env.admin(t, controllers.PermissionManageSettings)
editor := env.admin(t, controllers.PermissionAccessPosts)
env.expect(t, http.StatusUnauthorized, http.MethodGet, settings, "", nil)
env.expect(t, http.StatusForbidden, http.MethodGet, settings, editor, nil)
env.expect(t, http.StatusForbidden, http.MethodPut, settings, editor, map[string]any{"content_h1_to_h2": false})
env.expect(t, http.StatusForbidden, http.MethodPost, journalAPIPrefix+"/fix-headings", editor, map[string]any{})
decode := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data == nil {
t.Fatalf("settings body %s: %v", rec.Body.String(), err)
}
return body.Data
}
got := decode(t, env.expect(t, http.StatusOK, http.MethodGet, settings, manager, nil))
if got["content_h1_to_h2"] != true || got["generic_anchors"] != nil || got["rss_enabled"] != true || got["rss_posts_per_feed"] != float64(20) {
t.Fatalf("settings = %#v", got)
}
got = decode(t, env.expect(t, http.StatusOK, http.MethodPut, settings, manager, map[string]any{
"content_h1_to_h2": false, "generic_anchors": "click here\nhere",
}))
if got["content_h1_to_h2"] != false || got["generic_anchors"] != "click here\nhere" || got["rss_enabled"] != true {
t.Fatalf("updated settings = %#v", got)
}
var row models.Settings
if err := env.gdb.First(&row, 1).Error; err != nil {
t.Fatal(err)
}
if row.ContentH1ToH2 || row.GenericAnchors == nil || *row.GenericAnchors != "click here\nhere" {
t.Fatalf("stored settings = %+v", row)
}
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, settings, manager, map[string]any{"rss_posts_per_feed": 500})
if !strings.Contains(rec.Body.String(), "rss_posts_per_feed") {
t.Fatalf("invalid settings answer %s", rec.Body.String())
}
for key, bad := range map[string]any{"rss_image_url": "not a url", "generic_anchors": 5} {
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, settings, manager, map[string]any{key: bad})
if !strings.Contains(rec.Body.String(), key) {
t.Fatalf("invalid %s answer %s", key, rec.Body.String())
}
}
post, _ := seedStoredH1(t, env, "fix-api")
fix := journalAPIPrefix + "/fix-headings"
setH1ToH2(t, env, false)
rec = env.expect(t, http.StatusConflict, http.MethodPost, fix, manager, map[string]any{})
if !strings.Contains(rec.Body.String(), "H1 to H2 conversion is turned off") {
t.Fatalf("refusal %s", rec.Body.String())
}
setH1ToH2(t, env, true)
answer := func(rec *httptest.ResponseRecorder) map[string]any {
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("fix body %s: %v", rec.Body.String(), err)
}
return body
}
dry := answer(env.expect(t, http.StatusOK, http.MethodPost, fix, manager, map[string]any{}))
if dry["dry_run"] != true || dry["posts"] != float64(1) || dry["categories"] != float64(1) || dry["headings"] != float64(3) {
t.Fatalf("dry run answer = %#v", dry)
}
if storedPost(t, env, post).ContentHTML != "<h1>A</h1><p>x</p>" {
t.Fatal("dry run wrote the post")
}
applied := answer(env.expect(t, http.StatusOK, http.MethodPost, fix, manager, map[string]any{"dry_run": false}))
if applied["dry_run"] != false || applied["headings"] != float64(3) {
t.Fatalf("applied answer = %#v", applied)
}
if got := storedPost(t, env, post).ContentHTML; got != "<h2>A</h2><p>x</p>" {
t.Fatalf("applied post content_html = %q", got)
}
}

View File

@@ -122,6 +122,7 @@ func (p *Plugin) Commands() []bonfire.Command {
console.ExportPosts(p.app),
console.ImportPosts(p.app),
console.Reindex(p.app),
console.FixHeadings(p.app),
}
}

View File

@@ -31,6 +31,9 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Delete("/posts/{id}/featured-images/{fileId}", apicontrollers.DeleteFeaturedImage(p.app))
g.Where("id", `[0-9]+`)
g.Where("fileId", `[0-9]+`)
g.Get("/settings", apicontrollers.ShowSettings(p.app))
g.Put("/settings", apicontrollers.UpdateSettings(p.app))
g.Post("/fix-headings", apicontrollers.FixHeadings(p.app))
})
return nil
}