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
|
||||
}
|
||||
66
console/fix_headings.go
Normal file
66
console/fix_headings.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
53
controllers/api/maintenance.go
Normal file
53
controllers/api/maintenance.go
Normal 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
219
controllers/api/settings.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
184
headings_test.go
184
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": "<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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user