diff --git a/classes/image_alt.go b/classes/image_alt.go
new file mode 100644
index 0000000..b034f4e
--- /dev/null
+++ b/classes/image_alt.go
@@ -0,0 +1,92 @@
+package classes
+
+import (
+ "bytes"
+ "strings"
+
+ xhtml "golang.org/x/net/html"
+)
+
+// SetImageAlt sets the alt attribute of every img in html whose src
+// attribute (unescaped) equals src, adding it when missing. It returns the
+// new HTML, the number of matching images and the alt the first match had
+// (nil when it had none). It works on the x/net/html tokenizer: a matching
+// img tag is written again from its token, so every attribute value,
+// alt included, is escaped by the tokenizer and never concatenated; every
+// other byte of html is copied as it is. An empty alt marks a decorative
+// image (alt="").
+func SetImageAlt(html, src, alt string) (string, int, *string) {
+ if src == "" || !containsFold(html, "x & y

`
+ out, n, old := SetImageAlt(doc, "https://cdn.example.test/a.jpg?x=1&y=2", `Lion's "mane" `)
+ want := `x & y

`
+ if out != want || n != 1 || old != nil {
+ t.Fatalf("SetImageAlt = %q, %d, %v; want %q, 1, nil", out, n, old, want)
+ }
+ out, n, old = SetImageAlt(doc, "b.jpg", "")
+ if out != `x & y

` || n != 1 || old == nil || *old != "old" {
+ t.Fatalf("decorative = %q, %d, %v", out, n, old)
+ }
+ if out, n, _ := SetImageAlt(doc, "missing.jpg", "x"); out != doc || n != 0 {
+ t.Fatalf("missing = %q, %d", out, n)
+ }
+}
diff --git a/console/fix_headings.go b/console/fix_headings.go
index 943a295..e159878 100644
--- a/console/fix_headings.go
+++ b/console/fix_headings.go
@@ -13,7 +13,13 @@ import (
// FixHeadings is journal:fix-headings: it converts H1 to H2 in stored
// Journal rich text (post content in every locale and category pillar text)
// while the Journal H1 setting is on. It is a dry run unless --apply is
-// given; with the setting off it refuses with exit code 1.
+// given; with the setting off it refuses with exit code 1. Its output (UI-SPEC
+// C27 with the P33-54 wording, English only):
+//
+// - dry run: "Would convert :count H1 headings in :posts posts and :categories categories. Run with --apply to save."
+// - applied: "Converted :count H1 headings in :posts posts and :categories categories."
+// - nothing to do: "No H1 headings found in post or category content."
+// - refused (classes.H1ConversionOffText): "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings."
func FixHeadings(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "journal:fix-headings",
@@ -28,8 +34,10 @@ categories. Run with --apply to save."). With --apply it converts them in
one transaction, fires a record event per changed post and category (so an
SEO or search plugin hears about it) and queues the site cache purge.
-While the H1 setting is off the command refuses and exits with code 1. The
-MCP tool journal_fix_headings runs the same conversion.`,
+While the H1 setting is off the command prints "H1 to H2 conversion is
+turned off in the Journal settings (Headings and links). Turn it on first, or
+keep the H1 headings." and exits with code 1. The MCP tool
+journal_fix_headings runs the same conversion.`,
Flags: []bonfire.Flag{
{Name: "apply", Description: "Save the converted content (default: dry run)", Bare: true},
{Name: "dry-run", Description: "Only count the H1 headings (the default)", Bare: true},
diff --git a/console/import_posts.go b/console/import_posts.go
index bc26ee9..9d206d6 100644
--- a/console/import_posts.go
+++ b/console/import_posts.go
@@ -16,8 +16,8 @@ import (
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
- "git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/cabana"
+ "git.golem15.com/golem15/summercms/modules/compass"
"gorm.io/gorm"
)
diff --git a/controllers/api/maintenance.go b/controllers/api/maintenance.go
index 147377f..5b6d08b 100644
--- a/controllers/api/maintenance.go
+++ b/controllers/api/maintenance.go
@@ -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
+}
diff --git a/headings_test.go b/headings_test.go
index ce8e3e7..73a44e8 100644
--- a/headings_test.go
+++ b/headings_test.go
@@ -525,3 +525,93 @@ func TestJournalSettingsTools(t *testing.T) {
t.Fatalf("applied post content_html = %q", got)
}
}
+
+// TestSetImageAlt: POST /_journal/api/v1/images/alt sets alt on the
+// matching img of a post's content_html (default or translated), answers
+// the per-change diff, writes nothing on a dry run, refuses the whole batch
+// with 422 when one src is not in the post, escapes the alt through the
+// tokenizer (T-33-04) and needs golem15.journal.access_posts (D-85, P33-05).
+func TestSetImageAlt(t *testing.T) {
+ env := newPostsEnv(t)
+ editor := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
+ manager := env.admin(t, controllers.PermissionManageSettings)
+ const a, b = "https://cdn.example.test/a.jpg", "https://cdn.example.test/b.jpg"
+ body := `