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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user