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