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)
This commit is contained in:
Jakub Zych
2026-11-04 09:51:47 +01:00
parent 305f94afd9
commit faaa9b0f55
11 changed files with 459 additions and 42 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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")
}

View File

@@ -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()))}))
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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": `<h1 class="x">T</h1>`,
"translations": map[string]any{"pl": map[string]any{"title": "Nagłówek API", "content": `<h1 class="x">P</h1>`}},
})
id := apiPostID(t, rec)
p := storedPost(t, env, id)
if p.Content != `<h2 class="x">T</h2>` || !strings.Contains(p.ContentHTML, `<h2 class="x">T</h2>`) || strings.Contains(p.ContentHTML, "<h1") {
t.Fatalf("content = %q, content_html = %q, want the H2 with its class", p.Content, p.ContentHTML)
}
pl := translatedAttrs(t, env, id, "pl")
if pl["content"] != `<h2 class="x">P</h2>` || !strings.Contains(pl["content_html"].(string), `<h2 class="x">P</h2>`) {
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, "<h2") || strings.Contains(p.ContentHTML, "<h1") {
t.Fatalf("content = %q, content_html = %q, want markdown kept and an H2", p.Content, p.ContentHTML)
}
if notices := apiNotices(t, rec); len(notices) != 1 || notices[0]["message"] != postH1Phrase {
t.Fatalf("notices = %#v", notices)
}
if err := env.gdb.Exec(`UPDATE golem15_journal_posts SET content_html = '<h1>Old</h1>' WHERE id = ?`, id).Error; err != nil {
t.Fatal(err)
}
rec = env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", posts, id), token, map[string]any{"title": "Renamed"})
if notices := apiNotices(t, rec); notices != nil {
t.Fatalf("title-only update notices = %#v", notices)
}
if got := storedPost(t, env, id).ContentHTML; got != "<h1>Old</h1>" {
t.Fatalf("title-only update rewrote content_html: %q", got)
}
})
t.Run("API with the setting off stores H1", func(t *testing.T) {
setH1ToH2(t, env, false)
t.Cleanup(func() { setH1ToH2(t, env, true) })
rec := env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{
"title": "Kept heading", "content": `<h1 class="x">T</h1>`,
})
p := storedPost(t, env, apiPostID(t, rec))
if p.Content != `<h1 class="x">T</h1>` || !strings.Contains(p.ContentHTML, `<h1 class="x">T</h1>`) {
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: "<h1>I</h1><p>x</p>",
Translations: map[string]map[string]any{"pl": {"title": "Import", "content": "<h1>J</h1>"}},
})
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 != "<h2>I</h2><p>x</p>" || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h2>I</h2>") {
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), "<h2>J</h2>") {
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": "<h1>C</h1><p>x</p>",
})
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 != "<h2>C</h2><p>x</p>" {
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 != "<h1>C</h1><p>x</p>" || 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)
})
}

View File

@@ -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."

View File

@@ -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."