Blank content means content_html is the body (no 422 for content_html-only translations, a regression from the markdown alt fix); any other content is handled as markdown, so a body opening with a tag gets its inline image label and keeps its code samples. quick-261104-xae, review WR-02.
345 lines
12 KiB
Go
345 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, read as markdown (an
|
|
// HTML body included): 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. A locale whose content is blank (a translation that
|
|
// carries only content_html) gets the alt in content_html alone. 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
|
|
}
|
|
switch {
|
|
case strings.TrimSpace(text.content) == "":
|
|
// content_html is the body (a translation that holds
|
|
// only content_html, or a row without content): nothing
|
|
// else to keep in sync (33-REVIEW-GAPFIX WR-02).
|
|
default:
|
|
// content is read as markdown, as FormatHTML renders it
|
|
// on every API path, and the next save derives
|
|
// content_html from it again, so the alt must land in it
|
|
// (L1 WR-05): an inline image, or a raw img outside
|
|
// code. An HTML body (the rich editor's) is HTML blocks
|
|
// to goldmark, so its img is a raw img outside code and
|
|
// gets the alt here too. The first character no longer
|
|
// picks the branch, so markdown that opens with a tag
|
|
// keeps its code samples and is never answered changed
|
|
// and reverted later.
|
|
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
|
|
}
|