POST /_journal/api/v1/images/alt writes the alt of a markdown post into its source: the inline image  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.
337 lines
12 KiB
Go
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  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
|
|
}
|