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