From 305f94afd9d8fa36b01b40ecd7696e29c720cc5c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 09:44:58 +0100 Subject: [PATCH] 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) --- classes/blog_editor.go | 50 ++++++- classes/headings.go | 62 +++++++++ classes/headings_test.go | 30 +++++ classes/journal_settings.go | 84 ++++++++++++ config/config.yaml | 10 ++ controllers/api/posts.go | 14 ++ headings_test.go | 125 ++++++++++++++++++ lang/en/lang.yaml | 8 ++ lang/pl/lang.yaml | 10 ++ models/settings.go | 9 ++ models/settings/fields.yaml | 15 +++ plugin.go | 7 + ...1_add_golem15_journal_settings_headings.go | 43 ++++++ updates/category_content_migration_test.go | 21 ++- updates/settings_headings_migration_test.go | 72 ++++++++++ 15 files changed, 553 insertions(+), 7 deletions(-) create mode 100644 classes/headings.go create mode 100644 classes/headings_test.go create mode 100644 classes/journal_settings.go create mode 100644 config/config.yaml create mode 100644 headings_test.go create mode 100644 updates/202610280001_add_golem15_journal_settings_headings.go create mode 100644 updates/settings_headings_migration_test.go diff --git a/classes/blog_editor.go b/classes/blog_editor.go index 68685d0..64563e1 100644 --- a/classes/blog_editor.go +++ b/classes/blog_editor.go @@ -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 diff --git a/classes/headings.go b/classes/headings.go new file mode 100644 index 0000000..ac3ae00 --- /dev/null +++ b/classes/headings.go @@ -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, "= 3 { + b.WriteString("= 4 { + b.WriteString("A

x

", "

A

x

", 1}, + {`

T

`, `

T

`, 1}, + {"

Up

", "

Up

", 1}, + {"

a

\n

b

", "

a

\n

b

", 2}, + {"

no heading & text

", "

no heading & text

", 0}, + {"

x

", "

x

", 0}, + {"", "", 0}, + {"x

y

", "x

y

", 1}, + {"# Markdown

raw

a < b", "# Markdown

raw

a < b", 1}, + {"

unterminated

x", "

unterminated

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) + } + } +} diff --git a/classes/journal_settings.go b/classes/journal_settings.go new file mode 100644 index 0000000..3c5f8aa --- /dev/null +++ b/classes/journal_settings.go @@ -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 +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..9af0e1d --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,10 @@ +# golem15.journal defaults, read as golem15.journal.. +# +# 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 diff --git a/controllers/api/posts.go b/controllers/api/posts.go index f489e28..7f5ce9f 100644 --- a/controllers/api/posts.go +++ b/controllers/api/posts.go @@ -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 diff --git a/headings_test.go b/headings_test.go new file mode 100644 index 0000000..2006233 --- /dev/null +++ b/headings_test.go @@ -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 = "

A

x

" + + 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", "

A

x

", "

A

x

") + pl := translatedAttrs(t, env, id, "pl") + if pl["content"] != "

A

x

" || pl["content_html"] != "

A

x

" { + 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) + } + }) +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 6268e3c..ee3c41c 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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." diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 2572006..d300760 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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." diff --git a/models/settings.go b/models/settings.go index 63144f4..77e7674 100644 --- a/models/settings.go +++ b/models/settings.go @@ -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", } } diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml index 1197c2b..3cf9175 100644 --- a/models/settings/fields.yaml +++ b/models/settings/fields.yaml @@ -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 diff --git a/plugin.go b/plugin.go index de78163..83c1c2a 100644 --- a/plugin.go +++ b/plugin.go @@ -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() } diff --git a/updates/202610280001_add_golem15_journal_settings_headings.go b/updates/202610280001_add_golem15_journal_settings_headings.go new file mode 100644 index 0000000..0920e55 --- /dev/null +++ b/updates/202610280001_add_golem15_journal_settings_headings.go @@ -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...) +} diff --git a/updates/category_content_migration_test.go b/updates/category_content_migration_test.go index 4cf1873..980e813 100644 --- a/updates/category_content_migration_test.go +++ b/updates/category_content_migration_test.go @@ -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 = '

x

' 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") diff --git a/updates/settings_headings_migration_test.go b/updates/settings_headings_migration_test.go new file mode 100644 index 0000000..6246afb --- /dev/null +++ b/updates/settings_headings_migration_test.go @@ -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) + } +}