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 } // 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 }