diff --git a/README.md b/README.md index 91ae23c..8beaffa 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later - **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve. - **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`. - **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON. -- **Schema.** Seven gormigrate steps create the six journal tables and the author-slug column. AutoMigrate is not used. Table names are `golem15_journal_*` only. +- **Schema.** Eight gormigrate steps create the six journal tables, the author-slug column and the category `content` column. AutoMigrate is not used. Table names are `golem15_journal_*` only. ## Usage @@ -80,6 +80,16 @@ GET /_journal/api/v1/authors/jane-doe GET /_journal/api/v1/authors/jane-doe/posts?page=2 ``` +### Public categories API + +`GET /_journal/api/v1/categories` returns `{"data":[...]}`, a tree of category rows. Each row has the keys `id`, `name`, `slug`, `description`, `content`, `parent_id`, `nest_depth` and `post_count`, plus `children` only when the category has listed children. A category with no published posts and no listed children is omitted unless the request adds `include_empty=1`. + +`content` is the category's HTML body for a category landing page. It is stored and returned as is: it is not markdown and the API does not render, trim or sanitize it. It is `null` when unset, and it is the same in every locale, because it is a host column and not translatable. A consumer that renders it as HTML must apply its own gate or sanitizer first. + +```json +{"data":[{"id":4,"name":"Acme news","slug":"news","description":null,"content":"

Acme news

Pillar copy

","parent_id":null,"nest_depth":0,"post_count":3}]} +``` + ### Public author API `GET /_journal/api/v1/authors/{author}` returns one author's public profile without a token. `{author}` resolves in this order: @@ -157,7 +167,7 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post | `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. | | `models.Post.ToSearchableArray` | Typesense document built after a committed save. | | `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. | -| `models.Category` | Category row; `TableName` is `golem15_journal_categories`. | +| `models.Category` | Category row; `TableName` is `golem15_journal_categories`; `Content` holds the HTML body served as `content`. | | `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. | | `models.Category.Fillable` | `name`, `slug`, `code`, `description`, `parent_id`. | | `models.Tag` | Tag row; `TableName` is `golem15_journal_tags`. | diff --git a/controllers/api/categories_test.go b/controllers/api/categories_test.go new file mode 100644 index 0000000..45143f3 --- /dev/null +++ b/controllers/api/categories_test.go @@ -0,0 +1,62 @@ +package api + +import ( + "reflect" + "sort" + "testing" + + "git.golem15.com/golem15/sm-journal-plugin/models" +) + +func sortedCategoryKeys(row map[string]any) []string { + keys := make([]string, 0, len(row)) + for k := range row { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func TestSerializeCategoryNodeContent(t *testing.T) { + html := "

Acme

\n

Pillar copy

\n" + var parentID uint = 1 + root := models.Category{ID: 1, Name: "News", Slug: "news", Content: &html} + child := models.Category{ID: 2, Name: "Quiet", Slug: "quiet", ParentID: &parentID} + byParent := map[uint][]models.Category{1: {child}} + counts := map[uint]int64{1: 2} + + row := serializeCategoryNode(&root, byParent, counts, 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"} + if got := sortedCategoryKeys(row); !reflect.DeepEqual(got, wantRoot) { + t.Fatalf("root keys = %v, want %v", got, wantRoot) + } + kids, ok := row["children"].([]map[string]any) + if !ok || len(kids) != 1 { + t.Fatalf("children = %#v, want one row", row["children"]) + } + v, present := kids[0]["content"] + 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"} + if got := sortedCategoryKeys(kids[0]); !reflect.DeepEqual(got, wantChild) { + t.Fatalf("child keys = %v, want %v", got, wantChild) + } + + listed := serializeCategoryNode(&root, byParent, counts, false) + if listed == nil { + t.Fatal("root omitted without include_empty") + } + if _, has := listed["children"]; has { + t.Fatalf("empty child listed without include_empty: %#v", listed["children"]) + } + if got, ok := listed["content"].(string); !ok || got != html { + t.Fatalf("root content without include_empty = %#v, want %q", listed["content"], html) + } +} diff --git a/controllers/api/posts_helpers.go b/controllers/api/posts_helpers.go index 970bc6e..86faa25 100644 --- a/controllers/api/posts_helpers.go +++ b/controllers/api/posts_helpers.go @@ -195,6 +195,10 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego if c.Description != nil { desc = *c.Description } + var content any + if c.Content != nil { + content = *c.Content + } var parent any if c.ParentID != nil { parent = *c.ParentID @@ -208,6 +212,7 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego "name": c.Name, "slug": c.Slug, "description": desc, + "content": content, "parent_id": parent, "nest_depth": depth, "post_count": postCount, diff --git a/journal_api_categories_test.go b/journal_api_categories_test.go new file mode 100644 index 0000000..b096198 --- /dev/null +++ b/journal_api_categories_test.go @@ -0,0 +1,131 @@ +package journal_test + +import ( + "encoding/json" + "net/http" + "reflect" + "sort" + "testing" + "time" + + "git.golem15.com/golem15/sm-journal-plugin/models" +) + +// flattenCategoryRows walks the categories tree (data plus children) into one +// slice of rows. +func flattenCategoryRows(rows []map[string]any) []map[string]any { + var out []map[string]any + for _, row := range rows { + out = append(out, row) + if kids, ok := row["children"].([]any); ok { + nested := make([]map[string]any, 0, len(kids)) + for _, k := range kids { + if m, ok := k.(map[string]any); ok { + nested = append(nested, m) + } + } + out = append(out, flattenCategoryRows(nested)...) + } + } + return out +} + +func categoryRowsBySlug(t *testing.T, env *adminEnv, path string) map[string]map[string]any { + t.Helper() + rec := env.expect(t, http.StatusOK, http.MethodGet, path, "", nil) + var body struct { + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("categories json: %s err=%v", rec.Body.String(), err) + } + out := map[string]map[string]any{} + for _, row := range flattenCategoryRows(body.Data) { + slug, _ := row["slug"].(string) + out[slug] = row + } + return out +} + +func assertNullContent(t *testing.T, rows map[string]map[string]any, slug string) { + t.Helper() + row, ok := rows[slug] + if !ok { + t.Fatalf("category %s not listed", slug) + } + v, present := row["content"] + if !present || v != nil { + t.Fatalf("%s content = %#v (present %v), want JSON null", slug, v, present) + } +} + +func TestJournalCategoryContentAPI(t *testing.T) { + env := newPostsEnv(t) + html := "

Acme news

\n

Pillar copy

" + left, right, depth := 3, 4, 0 + news := models.Category{Name: "News", Slug: "news", Content: &html, NestLeft: &left, NestRight: &right, NestDepth: &depth} + if err := env.gdb.Create(&news).Error; err != nil { + t.Fatal(err) + } + quiet := models.Category{Name: "Quiet", Slug: "quiet"} + if err := env.gdb.Create(&quiet).Error; err != nil { + t.Fatal(err) + } + past := time.Now().UTC().Add(-time.Hour) + post := models.Post{Title: "Listed", Slug: "listed-content", Content: "x", Published: true, PublishedAt: &past} + if err := env.gdb.Create(&post).Error; err != nil { + t.Fatal(err) + } + if err := env.gdb.Create(&models.PostCategory{PostID: post.ID, CategoryID: news.ID}).Error; err != nil { + t.Fatal(err) + } + + all := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1") + if got, ok := all["news"]["content"].(string); !ok || got != html { + t.Fatalf("news content = %#v, want %q", all["news"]["content"], html) + } + assertNullContent(t, all, "quiet") + assertNullContent(t, all, "uncategorized") + + listed := categoryRowsBySlug(t, env, "/_journal/api/v1/categories") + if got, ok := listed["news"]["content"].(string); !ok || got != html { + t.Fatalf("news content without include_empty = %#v, want %q", listed["news"]["content"], html) + } + if _, ok := listed["quiet"]; ok { + t.Fatal("empty category quiet listed without include_empty") + } + + rec := env.expect(t, http.StatusOK, http.MethodGet, "/_journal/api/v1/posts", "", nil) + var posts struct { + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &posts); err != nil { + t.Fatalf("posts json: %s err=%v", rec.Body.String(), err) + } + var found bool + for _, p := range posts.Data { + if p["slug"] != "listed-content" { + continue + } + found = true + cats, ok := p["categories"].([]any) + if !ok || len(cats) == 0 { + t.Fatalf("post categories = %#v", p["categories"]) + } + first, ok := cats[0].(map[string]any) + if !ok { + t.Fatalf("post category entry = %#v", cats[0]) + } + keys := make([]string, 0, len(first)) + for k := range first { + keys = append(keys, k) + } + sort.Strings(keys) + if want := []string{"id", "name", "slug"}; !reflect.DeepEqual(keys, want) { + t.Fatalf("post category keys = %v, want %v", keys, want) + } + } + if !found { + t.Fatalf("post listed-content missing from /posts: %s", rec.Body.String()) + } +} diff --git a/models/category.go b/models/category.go index d8e7f19..0dc3659 100644 --- a/models/category.go +++ b/models/category.go @@ -9,6 +9,7 @@ type Category struct { Slug string `gorm:"column:slug"` Code *string `gorm:"column:code"` Description *string `gorm:"column:description"` + Content *string `gorm:"column:content"` // HTML body (D-21): stored and served as is, not translatable ParentID *uint `gorm:"column:parent_id"` NestLeft *int `gorm:"column:nest_left"` NestRight *int `gorm:"column:nest_right"` diff --git a/updates/202610090001_add_content_to_golem15_journal_categories.go b/updates/202610090001_add_content_to_golem15_journal_categories.go new file mode 100644 index 0000000..6f0a5f3 --- /dev/null +++ b/updates/202610090001_add_content_to_golem15_journal_categories.go @@ -0,0 +1,24 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// addCategoryContent adds the category HTML body (D-21). It must stay the last +// migration so RollbackLast drops only this column and keeps the rows. +var addCategoryContent = []*gormigrate.Migration{ + { + ID: "202610090001_add_content_to_golem15_journal_categories", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`ALTER TABLE golem15_journal_categories ADD COLUMN IF NOT EXISTS content TEXT NULL`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`ALTER TABLE golem15_journal_categories DROP COLUMN IF EXISTS content`).Error + }, + }, +} + +func init() { + Register(addCategoryContent...) +} diff --git a/updates/category_content_migration_test.go b/updates/category_content_migration_test.go new file mode 100644 index 0000000..4cf1873 --- /dev/null +++ b/updates/category_content_migration_test.go @@ -0,0 +1,82 @@ +package updates + +import ( + "database/sql" + "fmt" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon" + "github.com/go-gormigrate/gormigrate/v2" +) + +func TestCategoryContentMigration(t *testing.T) { + sqlDB := dedicatedDB(t, fmt.Sprintf("journal_catcontent_%d", time.Now().UnixNano())) + gdb, err := lagoon.Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + backend := gormigrate.New(gdb, &gormigrate.Options{ + TableName: "summer_migrations_summercms_cabana", + UseTransaction: true, + }, lagoon.BackendAdminMigrations) + if err := backend.Migrate(); err != nil { + t.Fatalf("backend_users migrations: %v", err) + } + m := journalMigrator(gdb) + if err := m.Migrate(); err != nil { + t.Fatalf("migrate: %v", err) + } + + all := All() + if last := all[len(all)-1].ID; last != "202610090001_add_content_to_golem15_journal_categories" { + t.Fatalf("last migration = %s, want 202610090001_add_content_to_golem15_journal_categories", last) + } + if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") { + t.Fatal("golem15_journal_categories.content missing") + } + assertUdt(t, gdb, "golem15_journal_categories", "content", "text") + var nullable string + if err := gdb.WithContext(t.Context()).Raw( + `SELECT is_nullable FROM information_schema.columns WHERE table_name = 'golem15_journal_categories' AND column_name = 'content'`, + ).Scan(&nullable).Error; err != nil { + t.Fatal(err) + } + if nullable != "YES" { + t.Fatalf("content is_nullable = %q, want YES", nullable) + } + + if err := gdb.Exec(`UPDATE golem15_journal_categories SET content = '

x

' WHERE slug = 'uncategorized'`).Error; err != nil { + t.Fatal(err) + } + if err := m.RollbackLast(); err != nil { + t.Fatalf("rollback last: %v", err) + } + if !gdb.Migrator().HasTable("golem15_journal_categories") { + t.Fatal("golem15_journal_categories dropped by the content rollback") + } + if gdb.Migrator().HasColumn("golem15_journal_categories", "content") { + t.Fatal("content column survived rollback") + } + var rows int64 + if err := gdb.Raw(`SELECT COUNT(*) FROM golem15_journal_categories WHERE slug = 'uncategorized'`).Scan(&rows).Error; err != nil { + t.Fatal(err) + } + if rows != 1 { + t.Fatalf("uncategorized rows after rollback = %d, want 1", rows) + } + + if err := m.Migrate(); err != nil { + t.Fatalf("re-migrate: %v", err) + } + if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") { + t.Fatal("content column missing after re-migrate") + } + var content sql.NullString + if err := gdb.Raw(`SELECT content FROM golem15_journal_categories WHERE slug = 'uncategorized'`).Scan(&content).Error; err != nil { + t.Fatal(err) + } + if content.Valid { + t.Fatalf("uncategorized content after re-migrate = %q, want NULL", content.String) + } +}