feat: H1 to H2 setting with the admin save conversion (tracer)

- content_h1_to_h2 and generic_anchors on the settings row (migration
  202610280001), Headings and links tab, config key
  golem15.journal.content_h1_to_h2 used when no settings row exists
- classes.ConvertH1 on the x/net/html tokenizer; the Blog Editor types
  convert while the setting is on and answer editor.h1_converted
- GET /posts/{slug} reports content_h1_to_h2 (additive key)
This commit is contained in:
Jakub Zych
2026-11-04 09:44:58 +01:00
parent f7bfb3313b
commit 305f94afd9
15 changed files with 553 additions and 7 deletions

View File

@@ -14,6 +14,7 @@ import (
"unicode/utf8"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"gocloud.dev/blob"
xhtml "golang.org/x/net/html"
@@ -31,6 +32,9 @@ const (
editorRemovedAttributes = "golem15.journal::lang.editor.removed_attributes"
editorRemovedComments = "golem15.journal::lang.editor.removed_comments"
editorMarkupChanged = "golem15.journal::lang.editor.markup_changed"
// EditorH1Converted is the notice of a save that turned H1 headings
// into H2 (Count is the number of headings).
EditorH1Converted = "golem15.journal::lang.editor.h1_converted"
)
// blogListValueRunes is how many runes of text a list column shows.
@@ -79,6 +83,7 @@ var (
// when unset).
func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
upload := uploadBlogImage(app)
decode := decodeBlogHTMLFor(app)
return []pact.FormFieldType{
{
Name: "blogeditor",
@@ -87,7 +92,7 @@ func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
Styles: []string{"assets/dist/blogeditor.css"},
Options: []string{"uploadFolder"},
CheckOptions: checkBlogOptions,
Decode: decodeBlogHTML,
Decode: decode,
Load: loadBlogHTML,
ListValue: blogListValue,
Upload: upload,
@@ -100,7 +105,7 @@ func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
Styles: []string{"assets/dist/blogeditor.css"},
Options: []string{"htmlColumn", "uploadFolder"},
CheckOptions: checkBlogOptions,
Decode: decodeBlogHTML,
Decode: decode,
Load: loadBlogHTML,
ListValue: blogListValue,
Upload: upload,
@@ -146,6 +151,47 @@ func checkUploadFolder(folder string) error {
return nil
}
// decodeBlogHTMLFor is the Blog Editor types' Decode: decodeBlogHTML, then,
// while the H1 setting is on (EffectiveH1ToH2 on the save transaction and
// the booted application's config), ConvertH1 on the stored HTML with the
// EditorH1Converted notice counting the converted headings. app may be nil
// or answer nil; the setting is then read from the database alone.
func decodeBlogHTMLFor(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
out, err := decodeBlogHTML(ctx, in)
if err != nil {
return out, err
}
text, isString := out.Value.(string)
if !isString || text == "" {
return out, nil
}
if !EffectiveH1ToH2(ctx, in.DB, appConfig(app)) {
return out, nil
}
converted, n := ConvertH1(text)
if n == 0 {
return out, nil
}
out.Value = converted
out.Notices = append(out.Notices, pact.FormFieldNotice{Message: EditorH1Converted, Count: n})
return out, nil
}
}
// appConfig is the config of the application app answers, nil when there
// is none.
func appConfig(app func() *backpack.App) *compass.Config {
if app == nil {
return nil
}
a := app()
if a == nil {
return nil
}
return a.Config
}
// decodeBlogHTML stores the HTML policy's output for a submitted value: the
// exact submitted bytes with no notice when the policy keeps everything,
// otherwise the stripped HTML with notices naming what was removed. nil

62
classes/headings.go Normal file
View File

@@ -0,0 +1,62 @@
package classes
import (
"bytes"
"strings"
xhtml "golang.org/x/net/html"
)
// ConvertH1 turns every h1 element of html into an h2 and returns the
// result with the number of headings it converted. It works on the
// x/net/html tokenizer's raw tokens: only the tag name of an h1 start, end
// or self-closing tag changes, and every other byte (attributes, text,
// comments, the content of script or style) is copied as it is. Text that
// merely looks like a tag inside raw-text elements or comments is not a
// tag and is not touched. html without an h1 comes back unchanged with 0.
func ConvertH1(html string) (string, int) {
if !containsFold(html, "<h1") && !containsFold(html, "</h1") {
return html, 0
}
z := xhtml.NewTokenizer(strings.NewReader(html))
var b bytes.Buffer
b.Grow(len(html))
count := 0
for {
tt := z.Next()
if tt == xhtml.ErrorToken {
break
}
raw := z.Raw()
switch tt {
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
if name, _ := z.TagName(); string(name) == "h1" && len(raw) >= 3 {
b.WriteString("<h2")
b.Write(raw[3:])
count++
continue
}
case xhtml.EndTagToken:
if name, _ := z.TagName(); string(name) == "h1" && len(raw) >= 4 {
b.WriteString("</h2")
b.Write(raw[4:])
continue
}
}
b.Write(raw)
}
if count == 0 && b.String() == html {
return html, 0
}
return b.String(), count
}
// CountH1 is the number of h1 start tags in html, as ConvertH1 counts them.
func CountH1(html string) int {
_, n := ConvertH1(html)
return n
}
func containsFold(s, sub string) bool {
return strings.Contains(strings.ToLower(s), sub)
}

30
classes/headings_test.go Normal file
View File

@@ -0,0 +1,30 @@
package classes
import "testing"
// TestConvertH1 pins ConvertH1's byte rule: only h1 tag names change.
func TestConvertH1(t *testing.T) {
cases := []struct {
in, want string
n int
}{
{"<h1>A</h1><p>x</p>", "<h2>A</h2><p>x</p>", 1},
{`<h1 class="x" id='y'>T</h1>`, `<h2 class="x" id='y'>T</h2>`, 1},
{"<H1>Up</H1>", "<h2>Up</h2>", 1},
{"<h1>a</h1>\n<h1>b</h1>", "<h2>a</h2>\n<h2>b</h2>", 2},
{"<p>no heading &amp; text</p>", "<p>no heading &amp; text</p>", 0},
{"<!-- <h1>c</h1> --><p>x</p>", "<!-- <h1>c</h1> --><p>x</p>", 0},
{"<script>var s = '<h1>';</script>", "<script>var s = '<h1>';</script>", 0},
{"<h10>x</h10><h1>y</h1>", "<h10>x</h10><h2>y</h2>", 1},
{"# Markdown <h1>raw</h1> a < b", "# Markdown <h2>raw</h2> a < b", 1},
{"<p>unterminated <h1>x", "<p>unterminated <h2>x", 1},
{"text <", "text <", 0},
{"", "", 0},
}
for _, c := range cases {
got, n := ConvertH1(c.in)
if got != c.want || n != c.n {
t.Errorf("ConvertH1(%q) = %q, %d; want %q, %d", c.in, got, n, c.want, c.n)
}
}
}

View File

@@ -0,0 +1,84 @@
package classes
import (
"context"
"strings"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/compass"
"gorm.io/gorm"
)
// H1ToH2Key is the config key of the H1 setting, used only when the Journal
// settings row does not exist (EffectiveH1ToH2). Its shipped default is true.
const H1ToH2Key = "golem15.journal.content_h1_to_h2"
// DefaultGenericAnchors are the link texts the editor warns about when the
// settings row has no list of its own: the English and the Polish seed list,
// each entry once.
var DefaultGenericAnchors = []string{
"click here", "here", "more", "read more", "learn more", "this link", "link",
"kliknij tutaj", "tutaj", "tu", "więcej", "czytaj więcej", "czytaj dalej", "ten link",
}
// headingSettings is the part of the settings row the heading rules read.
type headingSettings struct {
ContentH1ToH2 bool
GenericAnchors *string
}
// readHeadingSettings reads the settings row (ID 1); found is false when
// there is no row or no database.
func readHeadingSettings(ctx context.Context, db *gorm.DB) (headingSettings, bool) {
if db == nil {
return headingSettings{}, false
}
if ctx != nil {
db = db.WithContext(ctx)
}
var rows []headingSettings
if err := db.Model(&models.Settings{}).Select("content_h1_to_h2", "generic_anchors").
Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
return headingSettings{}, false
}
return rows[0], true
}
// EffectiveH1ToH2 is the one reader of the H1 setting (P33-04): the settings
// row's content_h1_to_h2 decides; without a row (or a database) the config
// key golem15.journal.content_h1_to_h2 does, and true when that is unset
// too. While it is true every H1 saved in Journal rich text becomes an H2.
func EffectiveH1ToH2(ctx context.Context, db *gorm.DB, cfg *compass.Config) bool {
if row, ok := readHeadingSettings(ctx, db); ok {
return row.ContentH1ToH2
}
if cfg != nil && cfg.Has(H1ToH2Key) {
return cfg.Bool(H1ToH2Key)
}
return true
}
// GenericAnchors is the list of generic link texts: the settings row's
// generic_anchors, one entry per line, trimmed and lowercased with blank
// lines dropped, or DefaultGenericAnchors when the row has no list (no
// row, NULL or only blank lines).
func GenericAnchors(ctx context.Context, db *gorm.DB) []string {
row, ok := readHeadingSettings(ctx, db)
if !ok || row.GenericAnchors == nil {
return append([]string(nil), DefaultGenericAnchors...)
}
var out []string
seen := map[string]bool{}
for _, line := range strings.Split(*row.GenericAnchors, "\n") {
entry := strings.ToLower(strings.TrimSpace(line))
if entry == "" || seen[entry] {
continue
}
seen[entry] = true
out = append(out, entry)
}
if len(out) == 0 {
return append([]string(nil), DefaultGenericAnchors...)
}
return out
}

10
config/config.yaml Normal file
View File

@@ -0,0 +1,10 @@
# golem15.journal defaults, read as golem15.journal.<key>.
#
# content_h1_to_h2 is the H1 setting used when the Journal settings row
# (Settings › Journal, Headings and links) does not exist; the row's own
# value decides otherwise. On, every H1 saved in Journal rich text (post
# content and category pillar text) becomes an H2 and the editor offers
# H2 to H4. Off, H1 headings are stored as written and the editor offers
# H1 to H4. Changing it never rewrites stored content; journal:fix-headings
# --apply does.
content_h1_to_h2: true

View File

@@ -10,8 +10,10 @@ import (
"time"
"unicode"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"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/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/cooler"
@@ -173,6 +175,10 @@ func Show(app *backpack.App) http.HandlerFunc {
rows = append(rows, serializePost(&related[i], db))
}
payload["related_posts"] = rows
// Additive (2026-10-28 Journal H1 setting): whether H1 in post
// content is stored as H2, so a frontend knows whether the page
// title is the only H1.
payload["content_h1_to_h2"] = classes.EffectiveH1ToH2(r.Context(), db, appConfig(app))
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": payload})
}
}
@@ -444,6 +450,14 @@ func UploadFeaturedImage(app *backpack.App) http.HandlerFunc {
}
}
// appConfig is app's config, nil without an application.
func appConfig(app *backpack.App) *compass.Config {
if app == nil {
return nil
}
return app.Config
}
// purgeSiteCache queues the host site's cache purge for post, as the admin
// save hooks do. The API writes run outside a transaction, so the purge is
// queued at once and never delays the response. Without site_cache.purge_url

125
headings_test.go Normal file
View File

@@ -0,0 +1,125 @@
package journal_test
import (
"encoding/json"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
)
const journalAPIPrefix = "/_journal/api/v1"
// setH1ToH2 switches the Journal H1 setting on the settings row.
func setH1ToH2(t *testing.T, env *adminEnv, on bool) {
t.Helper()
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET content_h1_to_h2 = ? WHERE id = 1`, on).Error; err != nil {
t.Fatal(err)
}
}
// publishPost publishes a post with a past published_at.
func publishPost(t *testing.T, env *adminEnv, id uint) {
t.Helper()
if err := env.gdb.Exec(`UPDATE golem15_journal_posts SET published = TRUE, published_at = NOW() - INTERVAL '1 hour' WHERE id = ?`, id).Error; err != nil {
t.Fatal(err)
}
}
// showH1Flag is GET /_journal/api/v1/posts/{slug}'s content_h1_to_h2.
func showH1Flag(t *testing.T, env *adminEnv, slug string) any {
t.Helper()
rec := env.expect(t, http.StatusOK, http.MethodGet, journalAPIPrefix+"/posts/"+slug, "", nil)
var body struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("show body %s: %v", rec.Body.String(), err)
}
flag, ok := body.Data["content_h1_to_h2"]
if !ok {
t.Fatalf("show answer has no content_h1_to_h2: %s", rec.Body.String())
}
return flag
}
// TestHeadingConversionTracer is the 33-02 tracer: an editor saves a post
// whose content holds an H1 through the admin and gets an H2 back with the
// h1_converted notice, and the public post answer reports the setting.
// With the setting off the same save keeps the H1, adds no notice, and the
// answer reports false.
func TestHeadingConversionTracer(t *testing.T) {
env := newPostsEnv(t)
token := env.admin(t,
controllers.PermissionAccessPosts,
controllers.PermissionAccessPublish,
controllers.PermissionAccessOtherPosts,
)
const submitted = "<h1>A</h1><p>x</p>"
save := func(t *testing.T, slug string) (uint, []adminNotice) {
t.Helper()
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
"title": map[string]any{"en": "Heading " + slug, "pl": "Nagłówek " + slug},
"slug": map[string]any{"en": slug, "pl": slug + "-pl"},
"content": map[string]any{"en": submitted, "pl": submitted},
})
return adminCreatedID(t, rec), adminNotices(t, rec)
}
t.Run("setting on", func(t *testing.T) {
setH1ToH2(t, env, true)
id, notices := save(t, "heading-on")
assertHostContent(t, env, id, "on", "<h2>A</h2><p>x</p>", "<h2>A</h2><p>x</p>")
pl := translatedAttrs(t, env, id, "pl")
if pl["content"] != "<h2>A</h2><p>x</p>" || pl["content_html"] != "<h2>A</h2><p>x</p>" {
t.Fatalf("pl content = %#v, content_html = %#v, want the H2", pl["content"], pl["content_html"])
}
for _, locale := range []string{"en", "pl"} {
found := false
for _, n := range notices {
if n.Field == "content" && n.Locale == locale &&
n.Message == "1 H1 heading was converted to H2, because the page shows the post title as the H1." {
found = true
}
}
if !found {
t.Fatalf("%s notices = %#v, want the h1_converted phrase with count 1", locale, notices)
}
}
publishPost(t, env, id)
if flag := showH1Flag(t, env, "heading-on"); flag != true {
t.Fatalf("content_h1_to_h2 = %#v, want true", flag)
}
})
t.Run("setting off", func(t *testing.T) {
setH1ToH2(t, env, false)
t.Cleanup(func() { setH1ToH2(t, env, true) })
id, notices := save(t, "heading-off")
assertHostContent(t, env, id, "off", submitted, submitted)
for _, n := range notices {
if strings.Contains(n.Message, "H1") {
t.Fatalf("setting off: notice %#v", n)
}
}
if notices != nil {
t.Fatalf("setting off: clean save carries meta.notices %#v", notices)
}
publishPost(t, env, id)
if flag := showH1Flag(t, env, "heading-off"); flag != false {
t.Fatalf("content_h1_to_h2 = %#v, want false", flag)
}
// D-50: switching the setting never rewrites stored content.
setH1ToH2(t, env, true)
var stored models.Post
if err := env.gdb.First(&stored, id).Error; err != nil {
t.Fatal(err)
}
if stored.Content != submitted || stored.ContentHTML != submitted {
t.Fatalf("switching the setting on rewrote content: %q / %q", stored.Content, stored.ContentHTML)
}
})
}

View File

@@ -55,6 +55,11 @@ journal:
rss_image_url_comment: URL to an image/logo displayed in feed readers.
rss_posts_per_feed_label: Posts per feed
rss_posts_per_feed_comment: Default number of posts in the RSS feed. Can be overridden per component.
tab_headings: Headings and links
h1_to_h2_label: Convert H1 to H2 in post and category content
h1_to_h2_comment: 'Turn it off only when the page template does not show the post title or the category name as the H1. On: the editor offers H2–H4, and every H1 saved through the admin, the API, MCP or an import becomes an H2. Off: content keeps its H1 headings, and the site shows them unchanged.'
generic_anchors_label: Generic link texts
generic_anchors_comment: One per line. The editor warns when the whole text of a link is one of these.
posts:
list_title: Manage the journal posts
filter_category: Category
@@ -157,3 +162,6 @@ editor:
one: ":count HTML comment was removed"
other: ":count HTML comments were removed"
markup_changed: The HTML filter changed some markup.
h1_converted:
one: ":count H1 heading was converted to H2, because the page shows the post title as the H1."
other: ":count H1 headings were converted to H2, because the page shows the post title as the H1."

View File

@@ -32,6 +32,11 @@ journal:
search_excerpt_weight_comment: Waga trafności dla dopasowań w zajawce posta (domyślnie 3).
search_content_weight_label: Waga treści
search_content_weight_comment: Waga trafności dla dopasowań w treści posta (domyślnie 1).
tab_headings: Nagłówki i linki
h1_to_h2_label: Zamieniaj H1 na H2 w treści wpisów i kategorii
h1_to_h2_comment: 'Wyłącz tylko wtedy, gdy szablon strony nie wyświetla tytułu wpisu ani nazwy kategorii jako H1. Włączone: edytor oferuje H2–H4, a każdy H1 zapisany przez panel, API, MCP lub import staje się H2. Wyłączone: treść zachowuje nagłówki H1, a witryna wyświetla je bez zmian.'
generic_anchors_label: Ogólnikowe teksty linków
generic_anchors_comment: Jeden w wierszu. Edytor ostrzega, gdy cały tekst linku jest jednym z nich.
posts:
list_title: Zarządzaj postami
filter_category: Kategoria
@@ -115,3 +120,8 @@ editor:
many: "Usunięto :count komentarzy HTML"
other: "Usunięto :count komentarza HTML"
markup_changed: Filtr HTML zmienił część znaczników.
h1_converted:
one: "Zamieniono :count nagłówek H1 na H2, bo strona wyświetla tytuł wpisu jako H1."
few: "Zamieniono :count nagłówki H1 na H2, bo strona wyświetla tytuł wpisu jako H1."
many: "Zamieniono :count nagłówków H1 na H2, bo strona wyświetla tytuł wpisu jako H1."
other: "Zamieniono :count nagłówka H1 na H2, bo strona wyświetla tytuł wpisu jako H1."

View File

@@ -17,6 +17,12 @@ type Settings struct {
RSSCopyright *string `gorm:"column:rss_copyright"`
RSSImageURL *string `gorm:"column:rss_image_url"`
RSSPostsPerFeed int `gorm:"column:rss_posts_per_feed"`
// ContentH1ToH2 turns H1 in Journal rich text (post content and
// category pillar text) into H2 on every write path.
ContentH1ToH2 bool `gorm:"column:content_h1_to_h2"`
// GenericAnchors lists the link texts the editor warns about, one per
// line; nil means the built-in lists.
GenericAnchors *string `gorm:"column:generic_anchors"`
}
func (Settings) TableName() string { return "golem15_journal_settings" }
@@ -27,6 +33,7 @@ func (Settings) Fillable() []string {
"search_title_weight", "search_excerpt_weight", "search_content_weight",
"rss_enabled", "rss_include_content", "rss_title", "rss_description",
"rss_language", "rss_copyright", "rss_image_url", "rss_posts_per_feed",
"content_h1_to_h2", "generic_anchors",
}
}
@@ -46,6 +53,8 @@ func (Settings) Rules() map[string]string {
"rss_copyright": "nullable|string|max:255",
"rss_image_url": "nullable|url|max:500",
"rss_posts_per_feed": "nullable|integer|min:1|max:100",
"content_h1_to_h2": "boolean",
"generic_anchors": "nullable|string|max:5000",
}
}

View File

@@ -13,6 +13,21 @@ fields:
type: switch
default: false
tab: golem15.journal::lang.journal.tab_general
content_h1_to_h2:
span: left
label: golem15.journal::lang.journal.h1_to_h2_label
comment: golem15.journal::lang.journal.h1_to_h2_comment
type: switch
default: true
tab: golem15.journal::lang.journal.tab_headings
generic_anchors:
span: full
label: golem15.journal::lang.journal.generic_anchors_label
comment: golem15.journal::lang.journal.generic_anchors_comment
type: textarea
size: small
default: "click here\nhere\nmore\nread more\nlearn more\nthis link\nlink\nkliknij tutaj\ntutaj\ntu\nwięcej\nczytaj więcej\nczytaj dalej\nten link"
tab: golem15.journal::lang.journal.tab_headings
search_use_typesense:
span: left
label: golem15.journal::lang.journal.search_use_typesense_label

View File

@@ -25,6 +25,7 @@ import (
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
@@ -33,6 +34,9 @@ var (
_ surf.BucketProvider = (*Plugin)(nil)
)
//go:embed config
var configFS embed.FS
//go:embed lang
var langFS embed.FS
@@ -104,6 +108,9 @@ func (p *Plugin) wireSiteCache(app *backpack.App) error {
return nil
}
// ConfigFS ships config/config.yaml (golem15.journal.content_h1_to_h2).
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func (p *Plugin) LangFS() fs.FS { return langFS }
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }

View File

@@ -0,0 +1,43 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// addSettingsHeadings adds the Headings and links settings to the
// golem15_journal_settings row: content_h1_to_h2 (on by default, so a site
// keeps converting H1 in Journal rich text to H2) and generic_anchors (the
// link texts the editor warns about, one per line; NULL means the built-in
// lists). Adding the columns rewrites no stored content.
var addSettingsHeadings = []*gormigrate.Migration{
{
ID: "202610280001_add_golem15_journal_settings_headings",
Migrate: func(tx *gorm.DB) error {
for _, stmt := range []string{
`ALTER TABLE golem15_journal_settings ADD COLUMN content_h1_to_h2 BOOLEAN NOT NULL DEFAULT TRUE`,
`ALTER TABLE golem15_journal_settings ADD COLUMN generic_anchors TEXT NULL`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
for _, stmt := range []string{
`ALTER TABLE golem15_journal_settings DROP COLUMN IF EXISTS generic_anchors`,
`ALTER TABLE golem15_journal_settings DROP COLUMN IF EXISTS content_h1_to_h2`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
},
}
func init() {
Register(addSettingsHeadings...)
}

View File

@@ -28,9 +28,20 @@ func TestCategoryContentMigration(t *testing.T) {
t.Fatalf("migrate: %v", err)
}
all := All()
if last := all[len(all)-1].ID; last != "202610090001_add_content_to_golem15_journal_categories" {
t.Fatalf("last migration = %s, want 202610090001_add_content_to_golem15_journal_categories", last)
// The content column's step and the one before it: later migrations
// are rolled back with it, so the test does not depend on being last.
const contentStep = "202610090001_add_content_to_golem15_journal_categories"
previous := ""
for i, mig := range All() {
if mig.ID == contentStep {
if i == 0 {
t.Fatalf("%s is the first migration", contentStep)
}
previous = All()[i-1].ID
}
}
if previous == "" {
t.Fatalf("migration %s is not registered", contentStep)
}
if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
t.Fatal("golem15_journal_categories.content missing")
@@ -49,8 +60,8 @@ func TestCategoryContentMigration(t *testing.T) {
if err := gdb.Exec(`UPDATE golem15_journal_categories SET content = '<p>x</p>' WHERE slug = 'uncategorized'`).Error; err != nil {
t.Fatal(err)
}
if err := m.RollbackLast(); err != nil {
t.Fatalf("rollback last: %v", err)
if err := m.RollbackTo(previous); err != nil {
t.Fatalf("rollback to %s: %v", previous, err)
}
if !gdb.Migrator().HasTable("golem15_journal_categories") {
t.Fatal("golem15_journal_categories dropped by the content rollback")

View File

@@ -0,0 +1,72 @@
package updates
import (
"database/sql"
"fmt"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"github.com/go-gormigrate/gormigrate/v2"
)
// TestSettingsHeadingsMigration checks the Headings and links columns:
// content_h1_to_h2 defaults to true on the existing settings row,
// generic_anchors is NULL, and the rollback drops both and keeps the row.
func TestSettingsHeadingsMigration(t *testing.T) {
sqlDB := dedicatedDB(t, fmt.Sprintf("journal_settings_headings_%d", time.Now().UnixNano()))
gdb, err := lagoon.Use(t.Context(), sqlDB)
if err != nil {
t.Fatal(err)
}
backend := gormigrate.New(gdb, &gormigrate.Options{
TableName: "summer_migrations_summercms_cabana",
UseTransaction: true,
}, lagoon.BackendAdminMigrations)
if err := backend.Migrate(); err != nil {
t.Fatalf("backend_users migrations: %v", err)
}
m := journalMigrator(gdb)
if err := m.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
assertUdt(t, gdb, "golem15_journal_settings", "content_h1_to_h2", "bool")
assertUdt(t, gdb, "golem15_journal_settings", "generic_anchors", "text")
var on bool
var anchors sql.NullString
if err := gdb.Raw(`SELECT content_h1_to_h2, generic_anchors FROM golem15_journal_settings WHERE id = 1`).Row().Scan(&on, &anchors); err != nil {
t.Fatal(err)
}
if !on || anchors.Valid {
t.Fatalf("settings row: content_h1_to_h2 = %v, generic_anchors = %v, want true and NULL", on, anchors)
}
const step = "202610280001_add_golem15_journal_settings_headings"
previous := ""
for i, mig := range All() {
if mig.ID == step && i > 0 {
previous = All()[i-1].ID
}
}
if previous == "" {
t.Fatalf("migration %s is not registered after another step", step)
}
if err := m.RollbackTo(previous); err != nil {
t.Fatalf("rollback to %s: %v", previous, err)
}
for _, column := range []string{"content_h1_to_h2", "generic_anchors"} {
if gdb.Migrator().HasColumn("golem15_journal_settings", column) {
t.Fatalf("%s survived rollback", column)
}
}
var rows int64
if err := gdb.Raw(`SELECT COUNT(*) FROM golem15_journal_settings WHERE id = 1`).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
if rows != 1 {
t.Fatalf("settings rows after rollback = %d, want 1", rows)
}
if err := m.Migrate(); err != nil {
t.Fatalf("re-migrate: %v", err)
}
}