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:
Jakub Zych
2026-11-04 10:10:52 +01:00
parent 5210b64e0f
commit af25eaa677
10 changed files with 597 additions and 15 deletions

92
classes/image_alt.go Normal file
View 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
View 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 &amp; y</p><img src="https://cdn.example.test/a.jpg?x=1&amp;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 &amp; y</p><img src="https://cdn.example.test/a.jpg?x=1&amp;y=2" alt="Lion&#39;s &#34;mane&#34; &lt;b&gt;"><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 &amp; y</p><img src="https://cdn.example.test/a.jpg?x=1&amp;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)
}
}