feat: MCP tools for settings, headings and image alt
- POST /_journal/api/v1/images/alt {dry_run, changes} sets alt on the
matching img of default or translated content through the tokenizer,
all or nothing (422), with a RecordSaved per changed post
- MCP journal_get_settings, journal_update_settings, journal_fix_headings
(manage_settings) and journal_set_image_alt (access_posts, destructive)
- journal:fix-headings help names its output and the refusal
This commit is contained in:
92
classes/image_alt.go
Normal file
92
classes/image_alt.go
Normal file
@@ -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, "<img") {
|
||||
return html, 0, nil
|
||||
}
|
||||
z := xhtml.NewTokenizer(strings.NewReader(html))
|
||||
var b bytes.Buffer
|
||||
b.Grow(len(html) + len(alt))
|
||||
matches := 0
|
||||
var old *string
|
||||
for {
|
||||
tt := z.Next()
|
||||
if tt == xhtml.ErrorToken {
|
||||
break
|
||||
}
|
||||
raw := z.Raw()
|
||||
if tt == xhtml.StartTagToken || tt == xhtml.SelfClosingTagToken {
|
||||
// Raw is copied before Token, which reads the tag; Token is
|
||||
// the only reader of the tag (TagName would consume it).
|
||||
kept := append([]byte(nil), raw...)
|
||||
tok := z.Token()
|
||||
if tok.Data == "img" && imgSrc(tok) == src {
|
||||
prev := setAlt(&tok, alt)
|
||||
if matches == 0 {
|
||||
old = prev
|
||||
}
|
||||
matches++
|
||||
b.WriteString(tok.String())
|
||||
continue
|
||||
}
|
||||
b.Write(kept)
|
||||
continue
|
||||
}
|
||||
b.Write(raw)
|
||||
}
|
||||
if matches == 0 {
|
||||
return html, 0, nil
|
||||
}
|
||||
return b.String(), matches, old
|
||||
}
|
||||
|
||||
func imgSrc(tok xhtml.Token) string {
|
||||
for _, a := range tok.Attr {
|
||||
if a.Namespace == "" && a.Key == "src" {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// setAlt sets tok's alt to alt and returns the previous value (nil when
|
||||
// there was none). Repeated alt attributes collapse to one.
|
||||
func setAlt(tok *xhtml.Token, alt string) *string {
|
||||
var prev *string
|
||||
attrs := tok.Attr[:0]
|
||||
set := false
|
||||
for _, a := range tok.Attr {
|
||||
if a.Namespace == "" && a.Key == "alt" {
|
||||
if prev == nil {
|
||||
v := a.Val
|
||||
prev = &v
|
||||
}
|
||||
if set {
|
||||
continue
|
||||
}
|
||||
a.Val = alt
|
||||
set = true
|
||||
}
|
||||
attrs = append(attrs, a)
|
||||
}
|
||||
if !set {
|
||||
attrs = append(attrs, xhtml.Attribute{Key: "alt", Val: alt})
|
||||
}
|
||||
tok.Attr = attrs
|
||||
return prev
|
||||
}
|
||||
22
classes/image_alt_test.go
Normal file
22
classes/image_alt_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package classes
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSetImageAlt pins the alt rewrite: only the matching img tag is
|
||||
// rewritten from its token, the alt is escaped, a missing alt is added and
|
||||
// every other byte is kept (T-33-04).
|
||||
func TestSetImageAlt(t *testing.T) {
|
||||
const doc = `<p class="a">x & y</p><img src="https://cdn.example.test/a.jpg?x=1&y=2"><img src="b.jpg" alt="old"/>`
|
||||
out, n, old := SetImageAlt(doc, "https://cdn.example.test/a.jpg?x=1&y=2", `Lion's "mane" <b>`)
|
||||
want := `<p class="a">x & y</p><img src="https://cdn.example.test/a.jpg?x=1&y=2" alt="Lion's "mane" <b>"><img src="b.jpg" alt="old"/>`
|
||||
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 != `<p class="a">x & y</p><img src="https://cdn.example.test/a.jpg?x=1&y=2"><img src="b.jpg" alt=""/>` || 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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 := `<p><img src="` + a + `"></p><p><img src="` + b + `" alt="old"></p>`
|
||||
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{
|
||||
"title": "Images", "content": body,
|
||||
"translations": map[string]any{"pl": map[string]any{"title": "Obrazy", "content": `<p><img src="` + a + `"></p>`}},
|
||||
}))
|
||||
path := journalAPIPrefix + "/images/alt"
|
||||
const tricky = `Lion's "mane" <b>`
|
||||
|
||||
env.expect(t, http.StatusUnauthorized, http.MethodPost, path, "", map[string]any{"changes": []any{}})
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, path, manager, map[string]any{"changes": []any{}})
|
||||
|
||||
decode := func(rec *httptest.ResponseRecorder) ([]map[string]any, any) {
|
||||
var out struct {
|
||||
Changes []map[string]any `json:"changes"`
|
||||
DryRun any `json:"dry_run"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("alt body %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return out.Changes, out.DryRun
|
||||
}
|
||||
|
||||
changes, dry := decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||
"dry_run": true,
|
||||
"changes": []any{map[string]any{"post_id": post, "src": a, "alt": tricky}},
|
||||
}))
|
||||
if dry != true || len(changes) != 1 || changes[0]["old_alt"] != nil || changes[0]["alt"] != tricky || changes[0]["changed"] != true {
|
||||
t.Fatalf("dry run answer = %#v, %v", changes, dry)
|
||||
}
|
||||
if got := storedPost(t, env, post).ContentHTML; strings.Contains(got, "alt=\"Lion") {
|
||||
t.Fatalf("dry run wrote: %q", got)
|
||||
}
|
||||
|
||||
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, path, editor, map[string]any{
|
||||
"changes": []any{
|
||||
map[string]any{"post_id": post, "src": a, "alt": "kept out"},
|
||||
map[string]any{"post_id": post, "src": "https://cdn.example.test/missing.jpg", "alt": "x"},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(rec.Body.String(), "changes.1.src") {
|
||||
t.Fatalf("missing src answer %s", rec.Body.String())
|
||||
}
|
||||
if got := storedPost(t, env, post).ContentHTML; strings.Contains(got, "kept out") {
|
||||
t.Fatalf("refused batch wrote: %q", got)
|
||||
}
|
||||
|
||||
records := listenRecords(t, env)
|
||||
changes, dry = decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||
"changes": []any{
|
||||
map[string]any{"post_id": post, "src": a, "alt": tricky},
|
||||
map[string]any{"post_id": post, "src": b, "alt": ""},
|
||||
map[string]any{"post_id": post, "locale": "pl", "src": a, "alt": "Lew"},
|
||||
},
|
||||
}))
|
||||
if dry != false || len(changes) != 3 || changes[1]["old_alt"] != "old" || changes[2]["locale"] != "pl" {
|
||||
t.Fatalf("applied answer = %#v, %v", changes, dry)
|
||||
}
|
||||
p := storedPost(t, env, post)
|
||||
for _, html := range []string{p.ContentHTML, p.Content} {
|
||||
if !strings.Contains(html, `alt="Lion's "mane" <b>"`) || strings.Contains(html, "<b>") || !strings.Contains(html, `alt=""`) {
|
||||
t.Fatalf("applied default = %q", html)
|
||||
}
|
||||
}
|
||||
pl := translatedAttrs(t, env, post, "pl")
|
||||
if !strings.Contains(pl["content_html"].(string), `alt="Lew"`) {
|
||||
t.Fatalf("applied pl = %#v", pl)
|
||||
}
|
||||
found := false
|
||||
for _, ev := range records.savedEvents() {
|
||||
if ev.ev.Model == p.MorphName() {
|
||||
_, def := findChange(ev.ev.Changes, "content_html", "")
|
||||
_, tr := findChange(ev.ev.Changes, "content_html", "pl")
|
||||
found = def && tr
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no RecordSaved with the default and pl content_html: %#v", records.savedEvents())
|
||||
}
|
||||
}
|
||||
|
||||
93
mcp.go
93
mcp.go
@@ -69,12 +69,58 @@ type mcpDeletePostInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the post id"`
|
||||
}
|
||||
|
||||
// mcpManageSettings is the permission of the settings and heading tools.
|
||||
const mcpManageSettings = "golem15.journal.manage_settings"
|
||||
|
||||
// mcpUpdateSettingsInput is PUT /_journal/api/v1/settings: only the fields
|
||||
// sent are changed.
|
||||
type mcpUpdateSettingsInput struct {
|
||||
ContentH1ToH2 *bool `json:"content_h1_to_h2,omitempty" jsonschema:"convert H1 to H2 in post and category content on every save; the editor then offers H2 to H4 (switching it never rewrites stored content, journal_fix_headings does)"`
|
||||
GenericAnchors *string `json:"generic_anchors,omitempty" jsonschema:"the link texts the editor warns about, one per line"`
|
||||
ShowAllPosts *bool `json:"show_all_posts,omitempty" jsonschema:"show drafts to administrators on the site"`
|
||||
UseRichEditor *bool `json:"use_rich_editor,omitempty" jsonschema:"use the rich editor for post content"`
|
||||
SearchUseTypesense *bool `json:"search_use_typesense,omitempty" jsonschema:"search posts with the configured search engine"`
|
||||
SearchTitleWeight *int `json:"search_title_weight,omitempty" jsonschema:"relevance weight of the title, 0 to 100"`
|
||||
SearchExcerptWeight *int `json:"search_excerpt_weight,omitempty" jsonschema:"relevance weight of the excerpt, 0 to 100"`
|
||||
SearchContentWeight *int `json:"search_content_weight,omitempty" jsonschema:"relevance weight of the content, 0 to 100"`
|
||||
RSSEnabled *bool `json:"rss_enabled,omitempty" jsonschema:"serve the RSS feed"`
|
||||
RSSIncludeContent *bool `json:"rss_include_content,omitempty" jsonschema:"include the full content in the feed"`
|
||||
RSSTitle *string `json:"rss_title,omitempty" jsonschema:"the feed title"`
|
||||
RSSDescription *string `json:"rss_description,omitempty" jsonschema:"the feed description"`
|
||||
RSSLanguage *string `json:"rss_language,omitempty" jsonschema:"the feed language code, for example en-us"`
|
||||
RSSCopyright *string `json:"rss_copyright,omitempty" jsonschema:"the feed copyright notice"`
|
||||
RSSImageURL *string `json:"rss_image_url,omitempty" jsonschema:"the feed image URL"`
|
||||
RSSPostsPerFeed *int `json:"rss_posts_per_feed,omitempty" jsonschema:"posts in the feed, 1 to 100"`
|
||||
}
|
||||
|
||||
// mcpFixHeadingsInput is POST /_journal/api/v1/fix-headings.
|
||||
type mcpFixHeadingsInput struct {
|
||||
DryRun *bool `json:"dry_run,omitempty" jsonschema:"only count the H1 headings (the default); false converts and saves them"`
|
||||
}
|
||||
|
||||
// mcpImageAltChange is one alt change of journal_set_image_alt.
|
||||
type mcpImageAltChange struct {
|
||||
PostID uint `json:"post_id" jsonschema:"the post id"`
|
||||
Locale string `json:"locale,omitempty" jsonschema:"the content locale; the default locale when omitted"`
|
||||
Src string `json:"src" jsonschema:"the src of the image in the post content, exactly as stored"`
|
||||
Alt string `json:"alt" jsonschema:"the alt text; empty marks the image as decorative"`
|
||||
}
|
||||
|
||||
// mcpSetImageAltInput is POST /_journal/api/v1/images/alt.
|
||||
type mcpSetImageAltInput struct {
|
||||
DryRun *bool `json:"dry_run,omitempty" jsonschema:"show the changes without saving them"`
|
||||
Changes []mcpImageAltChange `json:"changes" jsonschema:"the alt changes; all are refused when one names an image that is not in its post"`
|
||||
}
|
||||
|
||||
// MCP declares the Journal's MCP tools and prompt (D-15, D-16): the post,
|
||||
// category and tag routes of /_journal/api/v1, run as the backend account
|
||||
// linked to the MCP user and listed only when that account has
|
||||
// golem15.journal.access_posts. The routes enforce their own permissions
|
||||
// (publishing, other authors' posts) on every call. Media and featured-image
|
||||
// uploads (multipart) are not tools.
|
||||
// golem15.journal.access_posts; the settings, fix_headings and
|
||||
// set_image_alt tools of the content fixes (D-10, D-85) are listed with
|
||||
// golem15.journal.manage_settings (set_image_alt with access_posts). The
|
||||
// routes enforce their own permissions (publishing, other authors' posts)
|
||||
// on every call. Media and featured-image uploads (multipart) are not
|
||||
// tools.
|
||||
func (p *Plugin) MCP() pact.MCP {
|
||||
destructive := true
|
||||
return pact.MCP{
|
||||
@@ -150,6 +196,47 @@ func (p *Plugin) MCP() pact.MCP {
|
||||
Permissions: []string{mcpAccessPosts},
|
||||
Destructive: &destructive,
|
||||
},
|
||||
{
|
||||
Name: "get_settings",
|
||||
Title: "Get Journal settings",
|
||||
Description: "Shows the Journal settings: the H1 to H2 conversion, the generic link texts, search and RSS.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_journal/api/v1/settings",
|
||||
Input: struct{}{},
|
||||
Permissions: []string{mcpManageSettings},
|
||||
},
|
||||
{
|
||||
Name: "update_settings",
|
||||
Title: "Update Journal settings",
|
||||
Description: "Changes the Journal settings sent, such as the H1 to H2 conversion or the generic link texts.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPut,
|
||||
Path: "/_journal/api/v1/settings",
|
||||
Input: mcpUpdateSettingsInput{},
|
||||
Permissions: []string{mcpManageSettings},
|
||||
},
|
||||
{
|
||||
Name: "fix_headings",
|
||||
Title: "Fix stored H1 headings",
|
||||
Description: "Converts H1 headings to H2 in stored post and category content, like journal:fix-headings; a dry run unless dry_run is false. Refused while the H1 setting is off.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPost,
|
||||
Path: "/_journal/api/v1/fix-headings",
|
||||
Input: mcpFixHeadingsInput{},
|
||||
Permissions: []string{mcpManageSettings},
|
||||
},
|
||||
{
|
||||
Name: "set_image_alt",
|
||||
Title: "Set image alt texts",
|
||||
Description: "Sets the alt text of images in post content, several at once; an empty alt marks an image as decorative.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPost,
|
||||
Path: "/_journal/api/v1/images/alt",
|
||||
Input: mcpSetImageAltInput{},
|
||||
Permissions: []string{mcpAccessPosts},
|
||||
Destructive: &destructive,
|
||||
},
|
||||
},
|
||||
Prompts: []pact.MCPPrompt{{
|
||||
Name: "draft_post",
|
||||
|
||||
@@ -17,8 +17,10 @@ import (
|
||||
)
|
||||
|
||||
// TestJournalMCPSmoke pins the Journal's MCP declaration (P-17, D-15, D-16):
|
||||
// exactly the seven post, category and tag tools and the draft_post prompt,
|
||||
// each with Guard backend and golem15.journal.access_posts, delete marked
|
||||
// exactly the seven post, category and tag tools, the four settings and
|
||||
// content-fix tools (33-02) and the draft_post prompt, each with Guard
|
||||
// backend and golem15.journal.access_posts (manage_settings for the
|
||||
// settings and fix_headings tools), delete and set_image_alt marked
|
||||
// destructive, and every tool bound to a route the plugin registers (the
|
||||
// same method and pattern in a router built from the booted plugins).
|
||||
// Runs under -short (no database).
|
||||
@@ -26,17 +28,25 @@ func TestJournalMCPSmoke(t *testing.T) {
|
||||
var _ pact.HasMCP = (*journal.Plugin)(nil)
|
||||
decl := (&journal.Plugin{}).MCP()
|
||||
|
||||
wantTools := []string{"list_posts", "show_post", "list_categories", "list_tags", "create_post", "update_post", "delete_post"}
|
||||
wantTools := []string{"list_posts", "show_post", "list_categories", "list_tags", "create_post", "update_post", "delete_post",
|
||||
"get_settings", "update_settings", "fix_headings", "set_image_alt"}
|
||||
// The settings and heading tools need manage_settings (33-02, D-10).
|
||||
manageSettings := map[string]bool{"get_settings": true, "update_settings": true, "fix_headings": true}
|
||||
destructive := map[string]bool{"delete_post": true, "set_image_alt": true}
|
||||
var names []string
|
||||
for _, tool := range decl.Tools {
|
||||
names = append(names, tool.Name)
|
||||
if tool.Guard != "backend" || !slices.Equal(tool.Permissions, []string{"golem15.journal.access_posts"}) {
|
||||
t.Errorf("tool %s guard %q permissions %v, want backend and access_posts", tool.Name, tool.Guard, tool.Permissions)
|
||||
want := []string{"golem15.journal.access_posts"}
|
||||
if manageSettings[tool.Name] {
|
||||
want = []string{"golem15.journal.manage_settings"}
|
||||
}
|
||||
if tool.Guard != "backend" || !slices.Equal(tool.Permissions, want) {
|
||||
t.Errorf("tool %s guard %q permissions %v, want backend and %v", tool.Name, tool.Guard, tool.Permissions, want)
|
||||
}
|
||||
if tool.Title == "" || tool.Description == "" || tool.Input == nil {
|
||||
t.Errorf("tool %s lacks a title, description or input", tool.Name)
|
||||
}
|
||||
if (tool.Destructive != nil && *tool.Destructive) != (tool.Name == "delete_post") {
|
||||
if (tool.Destructive != nil && *tool.Destructive) != destructive[tool.Name] {
|
||||
t.Errorf("tool %s destructive = %v", tool.Name, tool.Destructive)
|
||||
}
|
||||
}
|
||||
|
||||
16
mcp_test.go
16
mcp_test.go
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
@@ -24,6 +25,13 @@ import (
|
||||
var (
|
||||
postBodyKeys = []string{"categories", "content", "excerpt", "is_pinned", "metadata", "published", "published_at", "slug", "sources", "tags", "title", "translations"}
|
||||
postQueryKeys = []string{"author", "category", "include_empty", "page", "per_page", "pinned_first", "published", "search", "sort", "tag"}
|
||||
// maintenanceBodyKeys are the body keys of the settings and content
|
||||
// fix routes, by path.
|
||||
maintenanceBodyKeys = map[string][]string{
|
||||
"/_journal/api/v1/settings": models.Settings{}.Fillable(),
|
||||
"/_journal/api/v1/fix-headings": {"dry_run"},
|
||||
"/_journal/api/v1/images/alt": {"dry_run", "changes"},
|
||||
}
|
||||
)
|
||||
|
||||
// mountHost adapts a built surf router to pier.Host for a compile-only mount.
|
||||
@@ -102,7 +110,7 @@ func TestJournalMCP(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("pier was not mounted")
|
||||
}
|
||||
want := []string{"journal_create_post", "journal_delete_post", "journal_list_categories", "journal_list_posts", "journal_list_tags", "journal_show_post", "journal_update_post"}
|
||||
want := []string{"journal_create_post", "journal_delete_post", "journal_fix_headings", "journal_get_settings", "journal_list_categories", "journal_list_posts", "journal_list_tags", "journal_set_image_alt", "journal_show_post", "journal_update_post", "journal_update_settings"}
|
||||
if got := p.ToolNames(); !slices.Equal(got, want) {
|
||||
t.Fatalf("compiled tools %v, want %v", got, want)
|
||||
}
|
||||
@@ -127,7 +135,11 @@ func TestJournalMCP(t *testing.T) {
|
||||
t.Errorf("%s.%s: query key %q is not one the list reads", tool.Name, f.Name, f.Tag.Get("query"))
|
||||
}
|
||||
default:
|
||||
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete || !slices.Contains(postBodyKeys, key) {
|
||||
keys := postBodyKeys
|
||||
if routeKeys, ok := maintenanceBodyKeys[tool.Path]; ok {
|
||||
keys = routeKeys
|
||||
}
|
||||
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete || !slices.Contains(keys, key) {
|
||||
t.Errorf("%s.%s: body key %q is not one %s %s reads", tool.Name, f.Name, key, tool.Method, tool.Path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Get("/settings", apicontrollers.ShowSettings(p.app))
|
||||
g.Put("/settings", apicontrollers.UpdateSettings(p.app))
|
||||
g.Post("/fix-headings", apicontrollers.FixHeadings(p.app))
|
||||
g.Post("/images/alt", apicontrollers.SetImageAlt(p.app))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user