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

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