feat: MCP tools for settings, headings and image alt

- POST /_journal/api/v1/images/alt {dry_run, changes} sets alt on the
  matching img of default or translated content through the tokenizer,
  all or nothing (422), with a RecordSaved per changed post
- MCP journal_get_settings, journal_update_settings, journal_fix_headings
  (manage_settings) and journal_set_image_alt (access_posts, destructive)
- journal:fix-headings help names its output and the refusal
This commit is contained in:
Jakub Zych
2026-11-04 10:10:52 +01:00
parent 5210b64e0f
commit af25eaa677
10 changed files with 597 additions and 15 deletions

View File

@@ -1,13 +1,30 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/models"
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
"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"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
// maxImageAltChanges caps one POST /images/alt batch.
const maxImageAltChanges = 500
// 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
@@ -51,3 +68,246 @@ func FixHeadings(app *backpack.App) http.HandlerFunc {
})
}
}
// imageAltChange is one requested alt change.
type imageAltChange struct {
postID uint
locale string
src string
alt string
}
// imageAltText is the stored body of one post in one locale ("" is the
// host columns) while a batch is applied.
type imageAltText struct {
post *models.Post
locale string
content, contentHTML string
oldContent, oldHTML string
}
// errForbiddenPost stops a batch that names a post the caller may not edit.
var errForbiddenPost = errors.New("forbidden post")
// SetImageAlt is POST /_journal/api/v1/images/alt {dry_run, changes:
// [{post_id, locale, src, alt}]}: it sets the alt attribute of the img whose
// src equals src in the post's content_html (and in content when that holds
// the same image), in the default locale when locale is empty and in that
// translation otherwise; alt "" marks a decorative image. It needs
// golem15.journal.access_posts and may edit only the posts the caller may
// edit (403). The batch is all or nothing: a missing post or an src that is
// not in the post refuses it with 422 (errors keyed changes.N.field). It
// answers {changes: [{post_id, locale, src, old_alt, alt, changed}],
// dry_run}; a dry run (dry_run true) writes nothing. Applied changes write
// in one transaction and fire one RecordSaved per changed post after the
// commit. Alt text is post content, so it is a Journal tool (P33-05):
// MCP journal_set_image_alt.
func SetImageAlt(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r, pr, ok := requireBackendPrincipal(app, w, r)
if !ok || !requireAccessPosts(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 := false
if v, has := body["dry_run"]; has && v != nil {
dry = asBool(v)
}
changes, verrs := parseImageAltChanges(body["changes"])
if len(verrs) > 0 {
writeValidation(w, verrs)
return
}
var answer []map[string]any
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
texts := map[string]*imageAltText{}
posts := map[uint]*models.Post{}
answer = make([]map[string]any, 0, len(changes))
for i, c := range changes {
post, found := posts[c.postID]
if !found {
p, err := findPostByID(tx, strconv.FormatUint(uint64(c.postID), 10))
if err != nil || p == nil {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.post_id", i), "The selected post is invalid.")
continue
}
if !p.CanEdit(pr) {
return errForbiddenPost
}
posts[c.postID], post = p, p
}
key := strconv.FormatUint(uint64(c.postID), 10) + "|" + c.locale
text, found := texts[key]
if !found {
text, err = loadImageAltText(ctx, tx, post, c.locale)
if err != nil {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.locale", i), "The locale is invalid.")
continue
}
texts[key] = text
}
html, n, old := classes.SetImageAlt(text.contentHTML, c.src, c.alt)
if n == 0 {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), "The image is not in the post.")
continue
}
text.contentHTML = html
if content, m, _ := classes.SetImageAlt(text.content, c.src, c.alt); m > 0 {
text.content = content
}
var oldAlt any
if old != nil {
oldAlt = *old
}
answer = append(answer, map[string]any{
"post_id": c.postID, "locale": c.locale, "src": c.src,
"old_alt": oldAlt, "alt": c.alt, "changed": old == nil || *old != c.alt,
})
}
if len(verrs) > 0 || dry {
return nil
}
return writeImageAltTexts(ctx, app, tx, texts)
})
switch {
case errors.Is(err, errForbiddenPost):
writeAPIError(w, http.StatusForbidden, errEditPermission)
return
case err != nil:
pluginLog.ErrorContext(r.Context(), "journal api: image alt failed", "error", err)
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
case len(verrs) > 0:
writeValidation(w, verrs)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"changes": answer, "dry_run": dry})
}
}
// parseImageAltChanges reads the changes array: post_id (a positive id),
// src (not blank) and alt (a string, "" allowed) are required, locale is
// optional.
func parseImageAltChanges(raw any) ([]imageAltChange, map[string][]string) {
items, ok := raw.([]any)
if !ok {
return nil, map[string][]string{"changes": {"The changes must be an array."}}
}
if len(items) > maxImageAltChanges {
return nil, map[string][]string{"changes": {fmt.Sprintf("The changes may not have more than %d items.", maxImageAltChanges)}}
}
var verrs map[string][]string
out := make([]imageAltChange, 0, len(items))
for i, item := range items {
m, ok := item.(map[string]any)
if !ok {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d", i), "The change must be an object.")
continue
}
var c imageAltChange
id, ok := asUint(m["post_id"])
if !ok || id == 0 {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.post_id", i), "The post id field is required.")
}
c.postID = id
c.src = strings.TrimSpace(asString(m["src"]))
if c.src == "" {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), "The src field is required.")
}
alt, isString := m["alt"].(string)
if !isString {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.alt", i), "The alt must be a string.")
}
c.alt = strings.TrimSpace(alt)
c.locale = strings.TrimSpace(asString(m["locale"]))
out = append(out, c)
}
return out, verrs
}
// loadImageAltText reads a post's content and content_html in locale: the
// host columns for "", the translation otherwise (the host columns for the
// default locale).
func loadImageAltText(ctx context.Context, tx *gorm.DB, post *models.Post, locale string) (*imageAltText, error) {
t := &imageAltText{post: post, locale: locale}
if locale == "" {
t.content, t.contentHTML = post.Content, post.ContentHTML
} else {
for _, field := range []string{"content", "content_html"} {
v, _, err := translate.TranslatedExact(ctx, tx, post, field, locale)
if err != nil {
return nil, err
}
text, _ := v.(string)
if field == "content" {
t.content = text
} else {
t.contentHTML = text
}
}
}
t.oldContent, t.oldHTML = t.content, t.contentHTML
return t, nil
}
// writeImageAltTexts stores the changed bodies and fires one RecordSaved
// per changed post, with a site cache purge.
func writeImageAltTexts(ctx context.Context, app *backpack.App, tx *gorm.DB, texts map[string]*imageAltText) error {
keys := make([]string, 0, len(texts))
for key := range texts {
keys = append(keys, key)
}
sort.Strings(keys)
byPost := map[uint][]pact.RecordChange{}
var order []*models.Post
for _, key := range keys {
t := texts[key]
var changes []pact.RecordChange
if t.content != t.oldContent {
changes = append(changes, pact.RecordChange{Attribute: "content", Locale: t.locale, Old: t.oldContent, New: t.content})
}
if t.contentHTML != t.oldHTML {
changes = append(changes, pact.RecordChange{Attribute: "content_html", Locale: t.locale, Old: t.oldHTML, New: t.contentHTML})
}
if len(changes) == 0 {
continue
}
if t.locale == "" {
if err := tx.Model(&models.Post{}).Where("id = ?", t.post.ID).
Updates(map[string]any{"content": t.content, "content_html": t.contentHTML}).Error; err != nil {
return err
}
} else {
for _, c := range changes {
if err := translate.SetTranslated(ctx, tx, t.post, c.Attribute, t.locale, c.New); err != nil {
return err
}
}
}
if _, seen := byPost[t.post.ID]; !seen {
order = append(order, t.post)
}
byPost[t.post.ID] = append(byPost[t.post.ID], changes...)
}
locale, _ := towel.Locale(ctx)
purger := cooler.SiteCacheFromApp(app)
for _, post := range order {
cabana.EmitRecordSaved(ctx, app, tx, pact.RecordSaved{
Model: post.MorphName(),
ID: strconv.FormatUint(uint64(post.ID), 10),
Locale: locale,
Changes: byPost[post.ID],
})
cooler.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug)
}
return nil
}