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

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

x & y

Lion's "mane" <b>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 != `

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 := `

old

` + 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": `

`}}, + })) + path := journalAPIPrefix + "/images/alt" + const tricky = `Lion's "mane" ` + + 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, "") || !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()) + } +} diff --git a/mcp.go b/mcp.go index a205445..4abf7b9 100644 --- a/mcp.go +++ b/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", diff --git a/mcp_smoke_test.go b/mcp_smoke_test.go index 308e987..cc0f2d8 100644 --- a/mcp_smoke_test.go +++ b/mcp_smoke_test.go @@ -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) } } diff --git a/mcp_test.go b/mcp_test.go index a60fcca..8d8e6bc 100644 --- a/mcp_test.go +++ b/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) } } diff --git a/routes.go b/routes.go index 4b35260..6cb2408 100644 --- a/routes.go +++ b/routes.go @@ -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 }