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

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