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

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 {