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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()))}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user