From faaa9b0f5538386e35d3d9439341f6b24ff01fd5 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 09:51:47 +0100 Subject: [PATCH] feat: H1 conversion on the API, markdown and import paths - the /_journal/api/v1 post writes convert default and translated content in the write transaction and answer an additive notices key - journal:import-posts converts and prints the converted count (ImportFromDirConfig, ImportStats.H1Converted) - a category's pillar text saved in the admin answers editor.h1_converted_category - every GET /categories row reports content_h1_to_h2 (additive key) --- classes/blog_editor.go | 21 ++- classes/headings.go | 36 +++++ console/export_posts.go | 9 ++ console/import_posts.go | 77 +++++++--- controllers/api/categories_test.go | 8 +- controllers/api/posts.go | 23 ++- controllers/api/posts_helpers.go | 94 ++++++++++++- controllers/posts.go | 7 +- headings_test.go | 218 +++++++++++++++++++++++++++++ lang/en/lang.yaml | 3 + lang/pl/lang.yaml | 5 + 11 files changed, 459 insertions(+), 42 deletions(-) diff --git a/classes/blog_editor.go b/classes/blog_editor.go index 64563e1..a719949 100644 --- a/classes/blog_editor.go +++ b/classes/blog_editor.go @@ -13,6 +13,7 @@ import ( "unicode" "unicode/utf8" + "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/pact" @@ -35,6 +36,9 @@ const ( // 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" + // EditorH1ConvertedCategory is EditorH1Converted for a category's + // pillar text, which names the category name instead of the post title. + EditorH1ConvertedCategory = "golem15.journal::lang.editor.h1_converted_category" ) // blogListValueRunes is how many runes of text a list column shows. @@ -153,8 +157,9 @@ func checkUploadFolder(folder string) error { // 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 +// the booted application's config), ConvertH1 on the stored HTML with a +// notice counting the converted headings: EditorH1ConvertedCategory on a +// category's pillar text, EditorH1Converted otherwise. 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) { @@ -174,11 +179,21 @@ func decodeBlogHTMLFor(app func() *backpack.App) func(context.Context, pact.Form return out, nil } out.Value = converted - out.Notices = append(out.Notices, pact.FormFieldNotice{Message: EditorH1Converted, Count: n}) + out.Notices = append(out.Notices, pact.FormFieldNotice{Message: h1ConvertedNotice(in.Record), Count: n}) return out, nil } } +// h1ConvertedNotice is the conversion notice for the saved record: the +// category phrase for a category, the post phrase otherwise. +func h1ConvertedNotice(record any) string { + switch record.(type) { + case *models.Category, models.Category: + return EditorH1ConvertedCategory + } + return EditorH1Converted +} + // appConfig is the config of the application app answers, nil when there // is none. func appConfig(app func() *backpack.App) *compass.Config { diff --git a/classes/headings.go b/classes/headings.go index ac3ae00..9224acd 100644 --- a/classes/headings.go +++ b/classes/headings.go @@ -60,3 +60,39 @@ func CountH1(html string) int { func containsFold(s, sub string) bool { return strings.Contains(strings.ToLower(s), sub) } + +// ConvertContentH1 applies ConvertH1 to a post body and its rendered HTML +// (content and content_html) and returns both with the number of headings +// converted: content_html's count, or content's when content_html had none +// (a markdown body has no h1 tag, its rendered HTML does). +func ConvertContentH1(content, contentHTML string) (string, string, int) { + content, nContent := ConvertH1(content) + contentHTML, nHTML := ConvertH1(contentHTML) + if nHTML > 0 { + return content, contentHTML, nHTML + } + return content, contentHTML, nContent +} + +// ConvertTranslationH1 applies ConvertContentH1 to the string content and +// content_html of one locale's translated attributes, in place, and returns +// the number of headings converted. Other attributes and values that are +// not strings are left alone. +func ConvertTranslationH1(attrs map[string]any) int { + if attrs == nil { + return 0 + } + content, hasContent := attrs["content"].(string) + html, hasHTML := attrs["content_html"].(string) + if !hasContent && !hasHTML { + return 0 + } + content, html, n := ConvertContentH1(content, html) + if hasContent { + attrs["content"] = content + } + if hasHTML { + attrs["content_html"] = html + } + return n +} diff --git a/console/export_posts.go b/console/export_posts.go index 1b0c7ef..6ec218f 100644 --- a/console/export_posts.go +++ b/console/export_posts.go @@ -13,6 +13,7 @@ import ( translatemodels "git.golem15.com/golem15/sm-translate-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/compass" "gorm.io/gorm" ) @@ -251,6 +252,14 @@ func isEmptyValue(v any) bool { return ok && s == "" } +// appConfig is app's config, nil without an application. +func appConfig(app *backpack.App) *compass.Config { + if app == nil { + return nil + } + return app.Config +} + func mustDB(app *backpack.App) *gorm.DB { if app == nil { return nil diff --git a/console/import_posts.go b/console/import_posts.go index ad85ee5..bc26ee9 100644 --- a/console/import_posts.go +++ b/console/import_posts.go @@ -16,6 +16,7 @@ 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" "gorm.io/gorm" ) @@ -33,6 +34,9 @@ type ImportStats struct { Updated int Skipped int Failed int + // H1Converted counts the H1 headings the import stored as H2 while the + // Journal H1 setting is on (default and translated content). + H1Converted int } // ImportPosts is journal:import-posts (PHP ImportPosts.php). @@ -65,13 +69,16 @@ failed".`, path := flagOrDefault(in, "path", defaultExportPath) force := flagOn(in, "force") dry := flagOn(in, "dry-run") - stats, err := ImportFromDir(ctx, mustDB(app), path, force, dry, out, nil) + stats, err := ImportFromDirConfig(ctx, mustDB(app), appConfig(app), path, force, dry, out, nil) if err != nil { return err } if dry { out.Info("Dry run complete (no changes made).") } + if stats.H1Converted > 0 { + out.Info(fmt.Sprintf("Converted %d H1 headings to H2.", stats.H1Converted)) + } out.Success(fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed", stats.Created, stats.Updated, stats.Skipped, stats.Failed)) return nil @@ -80,7 +87,17 @@ failed".`, } // ImportFromDir reads *.json files from dir and creates or updates posts. +// It is ImportFromDirConfig without a config: the Journal H1 setting is read +// from the settings row alone (on when there is none). func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry bool, out bonfire.Output, guard ImportGuard) (ImportStats, error) { + return ImportFromDirConfig(ctx, gdb, nil, dir, force, dry, out, guard) +} + +// ImportFromDirConfig is ImportFromDir with the application config the +// Journal H1 setting falls back to (classes.EffectiveH1ToH2). While the +// setting is on, every H1 the import stores becomes an H2, counted in +// ImportStats.H1Converted. +func ImportFromDirConfig(ctx context.Context, gdb *gorm.DB, cfg *compass.Config, dir string, force, dry bool, out bonfire.Output, guard ImportGuard) (ImportStats, error) { var stats ImportStats if gdb == nil { return stats, fmt.Errorf("journal: database is not configured") @@ -100,6 +117,7 @@ func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry boo return stats, fmt.Errorf("no JSON files found in: %s", dir) } work := func(tx *gorm.DB) error { + h1ToH2 := classes.EffectiveH1ToH2(ctx, tx, cfg) for _, file := range files { raw, err := os.ReadFile(file) if err != nil { @@ -124,10 +142,11 @@ func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry boo stats.Failed++ continue } - result, err := importPost(ctx, tx, data, force, dry, out, guard) + result, converted, err := importPost(ctx, tx, data, force, dry, h1ToH2, out, guard) if err != nil { return err } + stats.H1Converted += converted switch result { case "created": stats.Created++ @@ -153,16 +172,18 @@ func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry boo return stats, err } -func importPost(ctx context.Context, db *gorm.DB, data PostFile, force, dry bool, out bonfire.Output, guard ImportGuard) (string, error) { +// importPost writes one post file. converted is the number of H1 headings +// stored as H2 (h1ToH2 on); a dry run converts nothing. +func importPost(ctx context.Context, db *gorm.DB, data PostFile, force, dry, h1ToH2 bool, out bonfire.Output, guard ImportGuard) (string, int, error) { var existing models.Post err := db.Where("slug = ?", data.Slug).Take(&existing).Error found := err == nil if err != nil && err != gorm.ErrRecordNotFound { - return "failed", err + return "failed", 0, err } if found && !force { line(out, " [skip] %s (already exists)", data.Slug) - return "skipped", nil + return "skipped", 0, nil } post := &models.Post{} label := "created" @@ -184,44 +205,50 @@ func importPost(ctx context.Context, db *gorm.DB, data PostFile, force, dry bool fillPost(db, draft, data) if guard != nil { if err := guard(draft); err != nil { - return "failed", err + return "failed", 0, err } } if found { - return "updated", nil + return "updated", 0, nil } - return "created", nil + return "created", 0, nil } fillPost(db, post, data) if guard != nil { if err := guard(post); err != nil { - return "failed", err + return "failed", 0, err } } html, err := classes.FormatHTML(post.Content) if err != nil { - return "failed", err + return "failed", 0, err } post.ContentHTML = html + converted := 0 + if h1ToH2 { + post.Content, post.ContentHTML, converted = classes.ConvertContentH1(post.Content, post.ContentHTML) + } if found { if err := db.Save(post).Error; err != nil { - return "failed", err + return "failed", 0, err } } else { if err := db.Create(post).Error; err != nil { - return "failed", err + return "failed", 0, err } } if err := syncCategories(ctx, db, post, data, out); err != nil { - return "failed", err + return "failed", 0, err } - if err := syncTranslations(ctx, db, post, data.Translations); err != nil { - return "failed", err + n, err := syncTranslations(ctx, db, post, data.Translations, h1ToH2) + if err != nil { + return "failed", 0, err } + converted += n if found { - return "updated", nil + return "updated", converted, nil } - return "created", nil + return "created", converted, nil } func fillPost(db *gorm.DB, post *models.Post, data PostFile) { @@ -322,16 +349,22 @@ func titleFromSlug(slug string) string { return strings.Join(parts, " ") } -func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, translations map[string]map[string]any) error { +// syncTranslations writes the file's translations and returns the number +// of H1 headings stored as H2 (h1ToH2 on). +func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, translations map[string]map[string]any, h1ToH2 bool) (int, error) { if len(translations) == 0 { - return nil + return 0, nil } + converted := 0 fields := []string{"title", "slug", "content", "content_html", "excerpt", "metadata"} for locale, attrs := range translations { // A translated content_html is derived from content or sanitized, // as the host column is; Winter's ImportPosts stored it verbatim. if err := classes.FormatTranslation(attrs); err != nil { - return err + return 0, err + } + if h1ToH2 { + converted += classes.ConvertTranslationH1(attrs) } for _, field := range fields { val, ok := attrs[field] @@ -344,11 +377,11 @@ func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, trans } } if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil { - return err + return 0, err } } } - return nil + return converted, nil } func stripUnpublishedTitlePrefix(title string) string { diff --git a/controllers/api/categories_test.go b/controllers/api/categories_test.go index 45143f3..96617be 100644 --- a/controllers/api/categories_test.go +++ b/controllers/api/categories_test.go @@ -25,14 +25,14 @@ func TestSerializeCategoryNodeContent(t *testing.T) { byParent := map[uint][]models.Category{1: {child}} counts := map[uint]int64{1: 2} - row := serializeCategoryNode(&root, byParent, counts, true) + row := serializeCategoryNode(&root, byParent, counts, true, true) if row == nil { t.Fatal("root row is nil") } if got, ok := row["content"].(string); !ok || got != html { t.Fatalf("root content = %#v, want %q", row["content"], html) } - wantRoot := []string{"children", "content", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"} + wantRoot := []string{"children", "content", "content_h1_to_h2", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"} if got := sortedCategoryKeys(row); !reflect.DeepEqual(got, wantRoot) { t.Fatalf("root keys = %v, want %v", got, wantRoot) } @@ -44,12 +44,12 @@ func TestSerializeCategoryNodeContent(t *testing.T) { if !present || v != nil { t.Fatalf("child content = %#v (present %v), want nil", v, present) } - wantChild := []string{"content", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"} + wantChild := []string{"content", "content_h1_to_h2", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"} if got := sortedCategoryKeys(kids[0]); !reflect.DeepEqual(got, wantChild) { t.Fatalf("child keys = %v, want %v", got, wantChild) } - listed := serializeCategoryNode(&root, byParent, counts, false) + listed := serializeCategoryNode(&root, byParent, counts, false, true) if listed == nil { t.Fatal("root omitted without include_empty") } diff --git a/controllers/api/posts.go b/controllers/api/posts.go index 7f5ce9f..fe546f7 100644 --- a/controllers/api/posts.go +++ b/controllers/api/posts.go @@ -193,7 +193,8 @@ func Categories(app *backpack.App) http.HandlerFunc { return } includeEmpty := queryBool(r.URL.Query().Get("include_empty")) - data, err := serializeCategoryTree(db.WithContext(r.Context()), includeEmpty) + h1ToH2 := classes.EffectiveH1ToH2(r.Context(), db, appConfig(app)) + data, err := serializeCategoryTree(db.WithContext(r.Context()), includeEmpty, h1ToH2) if err != nil { writeAPIError(w, http.StatusInternalServerError, "Internal server error") return @@ -250,11 +251,16 @@ func Store(app *backpack.App) http.HandlerFunc { } // D-92/D-102: the row, its associations and translations commit // together, and RecordSaved fires only once they have. + var h1 *h1Conversion err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { + // D-50: while the H1 setting is on, H1 in the stored HTML + // becomes H2, in the same transaction as the write. + h1 = newH1Conversion(ctx, tx, app) + h1.convertPost(post) if err := tx.Create(post).Error; err != nil { return err } - tags, err := applyPostAssocs(ctx, tx, post, body) + tags, err := applyPostAssocs(ctx, tx, post, body, h1) if err != nil { return err } @@ -272,7 +278,7 @@ func Store(app *backpack.App) http.HandlerFunc { return } purgeSiteCache(r, app, post) - wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}) + wire.WriteJSON(w, http.StatusCreated, h1.answer(r, app, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})) } } @@ -318,11 +324,18 @@ func Update(app *backpack.App) http.HandlerFunc { } // D-92/D-102: a slug change commits with its translations and // associations, or not at all, and RecordSaved follows the commit. + var h1 *h1Conversion err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { + // D-50: only a submitted content is converted; an update + // that does not write the body leaves stored H1 alone. + h1 = newH1Conversion(ctx, tx, app) + if _, has := body["content"]; has { + h1.convertPost(post) + } if err := tx.Save(post).Error; err != nil { return err } - tags, err := applyPostAssocs(ctx, tx, post, body) + tags, err := applyPostAssocs(ctx, tx, post, body, h1) if err != nil { return err } @@ -340,7 +353,7 @@ func Update(app *backpack.App) http.HandlerFunc { return } purgeSiteCache(r, app, post) - wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}) + wire.WriteJSON(w, http.StatusOK, h1.answer(r, app, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})) } } diff --git a/controllers/api/posts_helpers.go b/controllers/api/posts_helpers.go index 4ace51a..3848851 100644 --- a/controllers/api/posts_helpers.go +++ b/controllers/api/posts_helpers.go @@ -17,8 +17,10 @@ import ( "git.golem15.com/golem15/sm-journal-plugin/models" translate "git.golem15.com/golem15/sm-translate-plugin/classes" translatemodels "git.golem15.com/golem15/sm-translate-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/towel" "gorm.io/gorm" ) @@ -171,7 +173,10 @@ func relatedPosts(db *gorm.DB, p *models.Post, editor bool) []models.Post { return rows } -func serializeCategoryTree(db *gorm.DB, includeEmpty bool) ([]map[string]any, error) { +// serializeCategoryTree is the category tree of GET /categories. h1ToH2 is +// the effective H1 setting every row reports as content_h1_to_h2 (additive +// key), so a frontend knows whether pillar text keeps H1 headings. +func serializeCategoryTree(db *gorm.DB, includeEmpty, h1ToH2 bool) ([]map[string]any, error) { var cats []models.Category if err := db.Order("nest_left ASC NULLS LAST, id ASC").Find(&cats).Error; err != nil { return nil, err @@ -188,21 +193,21 @@ func serializeCategoryTree(db *gorm.DB, includeEmpty bool) ([]map[string]any, er } out := make([]map[string]any, 0, len(roots)) for i := range roots { - if row := serializeCategoryNode(&roots[i], byParent, counts, includeEmpty); row != nil { + if row := serializeCategoryNode(&roots[i], byParent, counts, includeEmpty, h1ToH2); row != nil { out = append(out, row) } } return out, nil } -func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Category, counts map[uint]int64, includeEmpty bool) map[string]any { +func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Category, counts map[uint]int64, includeEmpty, h1ToH2 bool) map[string]any { if c == nil { return nil } kids := byParent[c.ID] childRows := make([]map[string]any, 0, len(kids)) for i := range kids { - if row := serializeCategoryNode(&kids[i], byParent, counts, includeEmpty); row != nil { + if row := serializeCategoryNode(&kids[i], byParent, counts, includeEmpty, h1ToH2); row != nil { childRows = append(childRows, row) } } @@ -235,6 +240,8 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego "parent_id": parent, "nest_depth": depth, "post_count": postCount, + // Additive (2026-10-28 H1 conversion scope). + "content_h1_to_h2": h1ToH2, } if len(childRows) > 0 { row["children"] = childRows @@ -698,7 +705,10 @@ func asUintSlice(raw any) ([]uint, bool) { // applyPostAssocs writes the post's categories, tags and translations the // body submits. created are the tags the write created by name, for their // record events. -func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) (created []models.Tag, err error) { +// +// h1 converts H1 in the translated content while the H1 setting is on; it +// may be nil. +func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any, h1 *h1Conversion) (created []models.Tag, err error) { if _, has := body["categories"]; has { ids, _ := asUintSlice(body["categories"]) if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil { @@ -726,7 +736,7 @@ func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body m } } if raw, has := body["translations"]; has { - if err := syncTranslations(ctx, db, post, raw); err != nil { + if err := syncTranslations(ctx, db, post, raw, h1); err != nil { return nil, err } } @@ -778,7 +788,7 @@ func resolveTagIDs(db *gorm.DB, raw any) (ids []uint, created []models.Tag, err return ids, created, nil } -func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any) error { +func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any, h1 *h1Conversion) error { m, ok := raw.(map[string]any) if !ok { return nil @@ -795,6 +805,7 @@ func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw a if err := classes.FormatTranslation(attrs); err != nil { return err } + h1.convertTranslation(locale, attrs) for _, field := range fields { if val, has := attrs[field]; has && val != nil { if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil { @@ -823,3 +834,72 @@ func requestLocale(r *http.Request) string { } return strings.ToLower(strings.TrimSpace(raw)) } + +// h1Conversion converts H1 to H2 in the post content an API write stores +// while the Journal H1 setting is on (D-50), and collects one notice per +// locale that had headings converted. A nil *h1Conversion converts nothing. +type h1Conversion struct { + on bool + locales []string + counts []int +} + +// newH1Conversion reads the H1 setting on tx (EffectiveH1ToH2). +func newH1Conversion(ctx context.Context, tx *gorm.DB, app *backpack.App) *h1Conversion { + return &h1Conversion{on: classes.EffectiveH1ToH2(ctx, tx, appConfig(app))} +} + +func (h *h1Conversion) add(locale string, n int) { + if n > 0 { + h.locales = append(h.locales, locale) + h.counts = append(h.counts, n) + } +} + +// convertPost converts the post's host content and content_html. +func (h *h1Conversion) convertPost(post *models.Post) { + if h == nil || !h.on || post == nil { + return + } + var n int + post.Content, post.ContentHTML, n = classes.ConvertContentH1(post.Content, post.ContentHTML) + h.add("", n) +} + +// convertTranslation converts one locale's submitted content and +// content_html, after FormatTranslation derived or sanitized them. +func (h *h1Conversion) convertTranslation(locale string, attrs map[string]any) { + if h == nil || !h.on { + return + } + h.add(locale, classes.ConvertTranslationH1(attrs)) +} + +// answer adds the additive notices key to a write answer when headings +// were converted: one {message, count} per locale, message being +// golem15.journal::lang.editor.h1_converted in the request locale, with the +// locale key on a translation's notice. Without a conversion the answer is +// unchanged. +func (h *h1Conversion) answer(r *http.Request, app *backpack.App, payload map[string]any) map[string]any { + if h == nil || len(h.counts) == 0 { + return payload + } + var tr *phrasebook.Translator + if app != nil { + tr, _ = app.Lookup[*phrasebook.Translator]() + } + notices := make([]map[string]any, 0, len(h.counts)) + for i, n := range h.counts { + message := classes.EditorH1Converted + if tr != nil { + message = tr.Choice(r.Context(), classes.EditorH1Converted, n, nil) + } + notice := map[string]any{"message": message, "count": n} + if h.locales[i] != "" { + notice["locale"] = h.locales[i] + } + notices = append(notices, notice) + } + payload["notices"] = notices + return payload +} diff --git a/controllers/posts.go b/controllers/posts.go index 15d29c2..115f983 100644 --- a/controllers/posts.go +++ b/controllers/posts.go @@ -12,6 +12,7 @@ import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) @@ -345,7 +346,11 @@ func (c postsAdminController) runExportPosts(ctx context.Context, in pact.AdminA func (c postsAdminController) runImportPosts(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) { path, dry, force := console.IOPayloadPath(in.Payload, "journal-export") - stats, err := console.ImportFromDir(ctx, c.db(ctx), path, force, dry, nil, func(post *models.Post) error { + var cfg *compass.Config + if app := c.application(); app != nil { + cfg = app.Config + } + stats, err := console.ImportFromDirConfig(ctx, c.db(ctx), cfg, path, force, dry, nil, func(post *models.Post) error { return refuseUnpublishedWrite(ctx, post) }) if err != nil { diff --git a/headings_test.go b/headings_test.go index 2006233..2a71a9b 100644 --- a/headings_test.go +++ b/headings_test.go @@ -2,12 +2,18 @@ package journal_test import ( "encoding/json" + "fmt" "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" "testing" + "git.golem15.com/golem15/sm-journal-plugin/console" "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" + "git.golem15.com/golem15/summercms/modules/bonfire" ) const journalAPIPrefix = "/_journal/api/v1" @@ -123,3 +129,215 @@ func TestHeadingConversionTracer(t *testing.T) { } }) } + +// cmdInput is a bonfire.Input of flags only. +type cmdInput map[string]string + +func (in cmdInput) Args() []string { return nil } +func (in cmdInput) Argument(string) (string, bool) { return "", false } +func (in cmdInput) Flags(string) []string { return nil } +func (in cmdInput) Flag(name string) (string, bool) { v, ok := in[name]; return v, ok } + +// apiNotices decodes a Journal API write answer's notices, nil when absent. +func apiNotices(t *testing.T, rec *httptest.ResponseRecorder) []map[string]any { + t.Helper() + var body map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("api body %s: %v", rec.Body.String(), err) + } + raw, ok := body["notices"] + if !ok { + return nil + } + var out []map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("notices %s: %v", raw, err) + } + return out +} + +// apiPostID is the id of a Journal API write answer's post. +func apiPostID(t *testing.T, rec *httptest.ResponseRecorder) uint { + t.Helper() + var body struct { + Data struct { + ID uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.ID == 0 { + t.Fatalf("api answer %s: %v", rec.Body.String(), err) + } + return body.Data.ID +} + +func storedPost(t *testing.T, env *adminEnv, id uint) models.Post { + t.Helper() + var p models.Post + if err := env.gdb.First(&p, id).Error; err != nil { + t.Fatal(err) + } + return p +} + +const postH1Phrase = "1 H1 heading was converted to H2, because the page shows the post title as the H1." + +// TestHeadingConversion covers every other Journal rich-text write path +// (D-50, CONTEXT 2026-10-28 H1 conversion scope): the API (and so MCP) +// create and update with default and translated content, the markdown +// path, journal:import-posts, a category's pillar text through the admin, +// and the content_h1_to_h2 flag of GET /categories. With the setting off +// nothing converts and no notice appears. +func TestHeadingConversion(t *testing.T) { + env := newPostsEnv(t) + token := env.admin(t, controllers.PermissionAccessPosts) + posts := journalAPIPrefix + "/posts" + + t.Run("API create converts default and translated content", func(t *testing.T) { + setH1ToH2(t, env, true) + rec := env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{ + "title": "API heading", + "content": `

T

`, + "translations": map[string]any{"pl": map[string]any{"title": "Nagłówek API", "content": `

P

`}}, + }) + id := apiPostID(t, rec) + p := storedPost(t, env, id) + if p.Content != `

T

` || !strings.Contains(p.ContentHTML, `

T

`) || strings.Contains(p.ContentHTML, "P` || !strings.Contains(pl["content_html"].(string), `

P

`) { + t.Fatalf("pl = %#v, want the H2", pl) + } + notices := apiNotices(t, rec) + if len(notices) != 2 || notices[0]["message"] != postH1Phrase || notices[0]["count"] != float64(1) || + notices[1]["locale"] != "pl" || notices[1]["count"] != float64(1) { + t.Fatalf("notices = %#v, want the default and the pl phrase with count 1", notices) + } + }) + + t.Run("API update converts markdown; an update without content converts nothing", func(t *testing.T) { + setH1ToH2(t, env, true) + id := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{ + "title": "Markdown heading", "content": "Body", + })) + rec := env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", posts, id), token, map[string]any{ + "content": "# T\n\nText", + }) + p := storedPost(t, env, id) + if p.Content != "# T\n\nText" || !strings.Contains(p.ContentHTML, "T`, + }) + p := storedPost(t, env, apiPostID(t, rec)) + if p.Content != `

T

` || !strings.Contains(p.ContentHTML, `

T

`) { + t.Fatalf("content = %q, content_html = %q, want the H1 kept", p.Content, p.ContentHTML) + } + if notices := apiNotices(t, rec); notices != nil { + t.Fatalf("setting off: notices %#v", notices) + } + }) + + t.Run("journal:import-posts converts and prints the count", func(t *testing.T) { + setH1ToH2(t, env, true) + dir := t.TempDir() + raw, err := json.Marshal(console.PostFile{ + Slug: "imported-heading", Title: "Imported", Content: "

I

x

", + Translations: map[string]map[string]any{"pl": {"title": "Import", "content": "

J

"}}, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "imported-heading.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + var buf strings.Builder + out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf) + if err := console.ImportPosts(env.app).Run(t.Context(), cmdInput{"path": dir}, out); err != nil { + t.Fatalf("import: %v (%s)", err, buf.String()) + } + if !strings.Contains(buf.String(), "Converted 2 H1 headings to H2.") { + t.Fatalf("import output %q, want the converted count", buf.String()) + } + var p models.Post + if err := env.gdb.Where("slug = ?", "imported-heading").Take(&p).Error; err != nil { + t.Fatal(err) + } + if p.Content != "

I

x

" || strings.Contains(p.ContentHTML, "I") { + t.Fatalf("imported content = %q, content_html = %q", p.Content, p.ContentHTML) + } + if pl := translatedAttrs(t, env, p.ID, "pl"); !strings.Contains(pl["content_html"].(string), "

J

") { + t.Fatalf("imported pl = %#v", pl) + } + }) + + t.Run("category pillar text through the admin", func(t *testing.T) { + catToken := env.admin(t, controllers.PermissionAccessCategories) + save := func(t *testing.T, slug string) (*string, []adminNotice) { + t.Helper() + rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), catToken, map[string]any{ + "name": map[string]any{"en": "Pillar " + slug, "pl": "Filar " + slug}, + "slug": map[string]any{"en": slug, "pl": slug + "-pl"}, + "content": "

C

x

", + }) + var cat models.Category + if err := env.gdb.First(&cat, adminCreatedID(t, rec)).Error; err != nil { + t.Fatal(err) + } + return cat.Content, adminNotices(t, rec) + } + setH1ToH2(t, env, true) + content, notices := save(t, "pillar-on") + if content == nil || *content != "

C

x

" { + t.Fatalf("category content = %#v, want the H2", content) + } + if len(notices) != 1 || notices[0].Field != "content" || + notices[0].Message != "1 H1 heading was converted to H2, because the page shows the category name as the H1." { + t.Fatalf("category notices = %#v", notices) + } + setH1ToH2(t, env, false) + t.Cleanup(func() { setH1ToH2(t, env, true) }) + content, notices = save(t, "pillar-off") + if content == nil || *content != "

C

x

" || notices != nil { + t.Fatalf("setting off: content = %#v, notices = %#v", content, notices) + } + }) + + t.Run("GET /categories reports the setting on every row", func(t *testing.T) { + for _, on := range []bool{true, false} { + setH1ToH2(t, env, on) + rec := env.expect(t, http.StatusOK, http.MethodGet, journalAPIPrefix+"/categories?include_empty=1", "", nil) + var body struct { + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) == 0 { + t.Fatalf("categories %s: %v", rec.Body.String(), err) + } + for _, row := range body.Data { + if row["content_h1_to_h2"] != on { + t.Fatalf("setting %v: row %#v", on, row) + } + } + } + setH1ToH2(t, env, true) + }) +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index ee3c41c..afb0c5f 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -165,3 +165,6 @@ editor: 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." + h1_converted_category: + one: ":count H1 heading was converted to H2, because the page shows the category name as the H1." + other: ":count H1 headings were converted to H2, because the page shows the category name as the H1." diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index d300760..09843b7 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -125,3 +125,8 @@ editor: 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." + h1_converted_category: + one: "Zamieniono :count nagłówek H1 na H2, bo strona wyświetla nazwę kategorii jako H1." + few: "Zamieniono :count nagłówki H1 na H2, bo strona wyświetla nazwę kategorii jako H1." + many: "Zamieniono :count nagłówków H1 na H2, bo strona wyświetla nazwę kategorii jako H1." + other: "Zamieniono :count nagłówka H1 na H2, bo strona wyświetla nazwę kategorii jako H1."