Files
sm-journal-plugin/controllers/api/maintenance.go
Jakub Zych 7662d54ab3 fix: image alt texts on markdown posts are written into the markdown
POST /_journal/api/v1/images/alt writes the alt of a markdown post into
its source: the inline image ![alt](src) outside code
(classes.SetMarkdownImageAlt) or a raw img outside code
(classes.SetImageAltOutsideCode), so the next content save keeps it. An
image the markdown does not hold that way is refused with 422
changes.N.src and nothing is written.
2026-11-04 23:20:02 +01:00

337 lines
12 KiB
Go

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
// 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,
})
}
}
// 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
}
// errMarkdownImage refuses an alt for an image a markdown post's source
// does not hold as an inline image or a raw img.
const errMarkdownImage = "The image is not an inline image of the post's markdown; edit the post."
// 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, in the default locale when
// locale is empty and in that translation otherwise; alt "" marks a
// decorative image. The post's content gets it too: an HTML body when it
// holds the same img; a markdown body in its inline image ![alt](src) or a
// raw img outside code (classes.SetMarkdownImageAlt,
// classes.SetImageAltOutsideCode), so the next content save keeps it. An
// image the markdown does not hold that way (a reference-style image, for
// example) refuses the change with 422 changes.N.src. 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
}
if classes.LooksLikeHTML(text.content) {
// An HTML body (the rich editor's) holds the same img.
if content, m, _ := classes.SetImageAlt(text.content, c.src, c.alt); m > 0 {
text.content = content
}
} else {
// A markdown body: the next save derives content_html
// from it again, so the alt must land in the markdown
// (L1 WR-05): an inline image, or a raw img outside code.
content, m, _ := classes.SetMarkdownImageAlt(text.content, c.src, c.alt)
content, r, _ := classes.SetImageAltOutsideCode(content, c.src, c.alt)
if m+r == 0 {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), errMarkdownImage)
continue
}
text.content = content
}
text.contentHTML = html
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
}