feat(15-02): register journal export/import commands and Posts toolbar

JSON CLI matching PHP ExportPosts/ImportPosts ships as HasCommands, and Posts toolbar actions reuse that logic behind access_import_export.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-06 18:47:53 +02:00
parent ab06569641
commit 4ff98f7de8
9 changed files with 770 additions and 5 deletions

View File

@@ -18,10 +18,11 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
## Features
- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName.
- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`/`mlmarkdown`. `classes.FormatHTML` regenerates `content_html` on admin save.
- **Categories and tags.** `models.Category` is translatable (name, description, slug). Fillable is name, slug, code, description, parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`.
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false.
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`.
- **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.
@@ -85,6 +86,7 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
| `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. |
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
| `journal.Plugin.Models` | Models registered by this plugin. |
| `journal.Plugin.Commands` | Registers `journal:export-posts` and `journal:import-posts`. |
| `models.Post` | Post row; `TableName` is `golem15_journal_posts`. |
| `models.Post.MorphName` | PHP class string `Golem15\Journal\Models\Post`. |
| `models.Post.Fillable` | Allow-list excluding `redactor_id`, `content_html`, and `user_id`. |
@@ -98,11 +100,26 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
| `models.All` | Models registered by this plugin. |
| `updates.All` | gormigrate steps registered by this plugin. |
## CLI commands
Both commands write JSON files (one per post slug), matching the PHP `journal:export-posts` / `journal:import-posts` tools. They are not a CSV ImportExport framework. A blog application binary registers them through `Plugin.Commands`.
| Command | Arguments and flags | Effect |
|---------|---------------------|--------|
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations. Attachments are omitted. |
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files. Existing slugs are skipped unless `--force` updates them. |
```sh
./bin/acme journal:export-posts --path journal-export
./bin/acme journal:import-posts --path journal-export --dry-run
./bin/acme journal:import-posts --path journal-export --force
```
## Dependencies
- SummerCMS modules: backpack, cabana, lagoon/attach, pact, party.
- SummerCMS modules: backpack, bonfire, cabana, lagoon/attach, pact, party.
- Compiled plugin: `golem15.translate`.
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`. Test-only: `github.com/testcontainers/testcontainers-go`.
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`. Test-only: `github.com/testcontainers/testcontainers-go`.
## Testing

19
console/columns.go Normal file
View File

@@ -0,0 +1,19 @@
package console
// ExportColumns is the Winter PostExport column set (table columns plus
// author_email, categories, featured_image_urls). The JSON CLI writes the
// ExportPosts.php document, not a CSV row; these names are the shared field
// list toolbar and CLI both read.
var ExportColumns = []string{
"id", "title", "slug", "excerpt", "content", "content_html",
"published", "published_at", "created_at", "updated_at",
"metadata", "sources", "author_email", "categories", "featured_image_urls",
}
// ImportColumns is the Winter PostImport column set. title, content, and slug
// are required. id, categories, and author_email are lookup keys, not host
// fillable columns.
var ImportColumns = []string{
"id", "title", "slug", "excerpt", "content",
"published", "published_at", "categories", "author_email",
}

253
console/export_posts.go Normal file
View File

@@ -0,0 +1,253 @@
package console
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"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/bonfire"
"gorm.io/gorm"
)
const defaultExportPath = "journal-export"
const translatableExportFields = "title,slug,content,content_html,excerpt,metadata"
// ExportPosts is journal:export-posts (PHP ExportPosts.php).
func ExportPosts(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "journal:export-posts",
Description: "Export all journal posts with translations to JSON files (one per post).",
Flags: []bonfire.Flag{
{Name: "path", Description: "Export directory (default: journal-export)"},
{Name: "dry-run", Description: "Show what would be exported without writing files", Bare: true},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
path := flagOrDefault(in, "path", defaultExportPath)
dry := flagOn(in, "dry-run")
n, attachments, err := ExportToDir(ctx, mustDB(app), path, dry, out)
if err != nil {
return err
}
if dry {
out.Info(fmt.Sprintf("Would export %d posts to: %s", n, path))
} else {
out.Success(fmt.Sprintf("Exported %d posts to: %s", n, path))
}
if attachments {
out.Warning("Note: File attachments (featured_images, content_images) are NOT included in the export.")
}
return nil
},
}
}
// PostFile is one exported JSON document (PHP ExportPosts::buildPostData).
type PostFile struct {
Slug string `json:"slug"`
Title string `json:"title"`
Content string `json:"content"`
ContentHTML string `json:"content_html"`
Excerpt string `json:"excerpt"`
Metadata any `json:"metadata"`
Sources any `json:"sources"`
Published bool `json:"published"`
PublishedAt *string `json:"published_at"`
Categories []string `json:"categories"`
Translations map[string]map[string]any `json:"translations"`
AuthorEmail string `json:"author_email,omitempty"`
CategoryNames map[string]string `json:"category_names,omitempty"`
}
// ExportToDir writes one JSON file per post into dir, or lists them when dry.
func ExportToDir(ctx context.Context, gdb *gorm.DB, dir string, dry bool, out bonfire.Output) (int, bool, error) {
if gdb == nil {
return 0, false, fmt.Errorf("journal: database is not configured")
}
var posts []models.Post
if err := gdb.WithContext(ctx).Preload("Categories").Preload("User").Find(&posts).Error; err != nil {
return 0, false, err
}
if len(posts) == 0 {
if out != nil {
out.Warning("No posts found.")
}
return 0, false, fmt.Errorf("no posts found")
}
if !dry {
if err := os.MkdirAll(dir, 0o755); err != nil {
return 0, false, err
}
}
exported := 0
attachments := false
for i := range posts {
data, err := buildPostFile(ctx, gdb, &posts[i])
if err != nil {
return exported, attachments, err
}
if dry {
status := "draft"
if posts[i].Published {
status = "published"
}
line(out, " [%s] %s (%d translations)", status, posts[i].Slug, len(data.Translations))
} else {
raw, err := json.MarshalIndent(data, "", " ")
if err != nil {
return exported, attachments, err
}
name := posts[i].Slug
if name == "" {
name = fmt.Sprintf("post-%d", posts[i].ID)
}
filePath := filepath.Join(dir, name+".json")
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
return exported, attachments, err
}
if err := os.WriteFile(filePath, raw, 0o644); err != nil {
return exported, attachments, err
}
}
exported++
}
return exported, attachments, nil
}
func buildPostFile(ctx context.Context, gdb *gorm.DB, post *models.Post) (PostFile, error) {
cats := make([]string, 0, len(post.Categories))
names := map[string]string{}
for _, c := range post.Categories {
cats = append(cats, c.Slug)
names[c.Slug] = c.Name
}
file := PostFile{
Slug: post.Slug,
Title: post.Title,
Content: post.Content,
ContentHTML: post.ContentHTML,
Excerpt: post.Excerpt,
Metadata: jsonField(post.Metadata),
Sources: jsonField(post.Sources),
Published: post.Published,
Categories: cats,
CategoryNames: names,
Translations: map[string]map[string]any{},
}
if post.PublishedAt != nil {
s := post.PublishedAt.UTC().Format("2006-01-02 15:04:05")
file.PublishedAt = &s
}
if post.User != nil {
file.AuthorEmail = post.User.Email
}
locales, defaultCode, err := enabledLocales(ctx, gdb)
if err != nil {
return file, err
}
fields := strings.Split(translatableExportFields, ",")
for _, locale := range locales {
if locale == defaultCode {
continue
}
trans := map[string]any{}
hasValue := false
for _, field := range fields {
val, ok, err := translate.TranslatedExact(ctx, gdb, post, field, locale)
if err != nil {
return file, err
}
if !ok {
trans[field] = nil
continue
}
trans[field] = val
if !isEmptyValue(val) {
hasValue = true
}
}
if hasValue {
file.Translations[locale] = trans
}
}
return file, nil
}
func enabledLocales(ctx context.Context, gdb *gorm.DB) ([]string, string, error) {
var rows []translatemodels.Locale
err := gdb.WithContext(ctx).Where("is_enabled = ?", true).Order("sort_order ASC, id ASC").Find(&rows).Error
if err != nil {
return nil, "en", err
}
codes := make([]string, 0, len(rows))
defaultCode := "en"
for _, row := range rows {
codes = append(codes, row.Code)
if row.IsDefault {
defaultCode = row.Code
}
}
return codes, defaultCode, nil
}
func jsonField(s *string) any {
if s == nil || strings.TrimSpace(*s) == "" {
return nil
}
var v any
if json.Unmarshal([]byte(*s), &v) == nil {
return v
}
return *s
}
func isEmptyValue(v any) bool {
if v == nil {
return true
}
s, ok := v.(string)
return ok && s == ""
}
func mustDB(app *backpack.App) *gorm.DB {
if app == nil {
return nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok {
return nil
}
return gdb
}
func flagOrDefault(in bonfire.Input, name, fallback string) string {
v, ok := in.Flag(name)
v = strings.TrimSpace(v)
if !ok || v == "" {
return fallback
}
return v
}
func flagOn(in bonfire.Input, name string) bool {
v, ok := in.Flag(name)
if !ok {
return false
}
s := strings.ToLower(strings.TrimSpace(v))
return s == "true" || s == "1" || s == "yes"
}
func line(out bonfire.Output, format string, args ...any) {
if out == nil {
return
}
out.Println(fmt.Sprintf(format, args...))
}

357
console/import_posts.go Normal file
View File

@@ -0,0 +1,357 @@
package console
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"unicode/utf8"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/models"
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/cabana"
"gorm.io/gorm"
)
// UnpublishedTitlePrefix is PHP Post::UNPUBLISHED_TITLE_PREFIX. Import strips
// it from titles so a draft-accessor export does not persist the lock mark.
const UnpublishedTitlePrefix = "🔒 "
// ImportGuard is an optional per-post check (toolbar publish permission).
type ImportGuard func(post *models.Post) error
// ImportStats is the PHP ImportPosts result line.
type ImportStats struct {
Created int
Updated int
Skipped int
Failed int
}
// ImportPosts is journal:import-posts (PHP ImportPosts.php).
func ImportPosts(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "journal:import-posts",
Description: "Import journal posts with translations from JSON files.",
Flags: []bonfire.Flag{
{Name: "path", Description: "Import directory (default: journal-export)"},
{Name: "force", Description: "Update existing posts (default: skip existing)", Bare: true},
{Name: "dry-run", Description: "Show what would be imported without making changes", Bare: true},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
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)
if err != nil {
return err
}
if dry {
out.Info("Dry run complete (no changes made).")
}
out.Info(fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed",
stats.Created, stats.Updated, stats.Skipped, stats.Failed))
return nil
},
}
}
// ImportFromDir reads *.json files from dir and creates or updates posts.
func ImportFromDir(ctx context.Context, gdb *gorm.DB, 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")
}
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return stats, fmt.Errorf("Import directory not found: %s", dir)
}
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
if err != nil {
return stats, err
}
if len(files) == 0 {
if out != nil {
out.Warning("No JSON files found in: " + dir)
}
return stats, fmt.Errorf("no JSON files found in: %s", dir)
}
work := func(tx *gorm.DB) error {
for _, file := range files {
raw, err := os.ReadFile(file)
if err != nil {
if out != nil {
out.Error(" Invalid JSON: " + filepath.Base(file))
}
stats.Failed++
continue
}
var data PostFile
if err := json.Unmarshal(raw, &data); err != nil {
if out != nil {
out.Error(" Invalid JSON: " + filepath.Base(file))
}
stats.Failed++
continue
}
if strings.TrimSpace(data.Slug) == "" {
if out != nil {
out.Error(" Missing slug in: " + filepath.Base(file))
}
stats.Failed++
continue
}
result, err := importPost(ctx, tx, data, force, dry, out, guard)
if err != nil {
return err
}
switch result {
case "created":
stats.Created++
case "updated":
stats.Updated++
case "skipped":
stats.Skipped++
default:
stats.Failed++
}
}
return nil
}
if dry {
if err := work(gdb.WithContext(ctx).Session(&gorm.Session{NewDB: true})); err != nil {
return stats, err
}
return stats, nil
}
err = gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return work(tx)
})
return stats, err
}
func importPost(ctx context.Context, db *gorm.DB, data PostFile, force, dry bool, out bonfire.Output, guard ImportGuard) (string, 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
}
if found && !force {
line(out, " [skip] %s (already exists)", data.Slug)
return "skipped", nil
}
post := &models.Post{}
label := "created"
if found {
post = &existing
label = "updated"
if dry {
label = "would update"
}
} else if dry {
label = "would create"
}
line(out, " [%s] %s", label, data.Slug)
if dry {
draft := &models.Post{}
if found {
*draft = existing
}
fillPost(db, draft, data)
if guard != nil {
if err := guard(draft); err != nil {
return "failed", err
}
}
if found {
return "updated", nil
}
return "created", nil
}
fillPost(db, post, data)
if guard != nil {
if err := guard(post); err != nil {
return "failed", err
}
}
html, err := classes.FormatHTML(post.Content)
if err != nil {
return "failed", err
}
post.ContentHTML = html
if found {
if err := db.Save(post).Error; err != nil {
return "failed", err
}
} else {
if err := db.Create(post).Error; err != nil {
return "failed", err
}
}
if err := syncCategories(ctx, db, post, data, out); err != nil {
return "failed", err
}
if err := syncTranslations(ctx, db, post, data.Translations); err != nil {
return "failed", err
}
if found {
return "updated", nil
}
return "created", nil
}
func fillPost(db *gorm.DB, post *models.Post, data PostFile) {
post.Title = stripUnpublishedTitlePrefix(data.Title)
post.Slug = data.Slug
post.Content = data.Content
post.Excerpt = data.Excerpt
post.Metadata = encodeJSONField(data.Metadata)
post.Sources = encodeJSONField(data.Sources)
post.Published = data.Published
post.PublishedAt = parsePublishedAt(data.PublishedAt)
if data.AuthorEmail == "" || post.UserID != nil {
return
}
var author cabana.BackendUser
if err := db.Where("email = ?", data.AuthorEmail).Take(&author).Error; err != nil {
return
}
id := author.ID
post.UserID = &id
}
func parsePublishedAt(s *string) *time.Time {
if s == nil || strings.TrimSpace(*s) == "" {
return nil
}
raw := strings.TrimSpace(*s)
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z", "2006-01-02"} {
if t, err := time.Parse(layout, raw); err == nil {
return &t
}
}
return nil
}
func encodeJSONField(v any) *string {
if v == nil {
return nil
}
if s, ok := v.(string); ok {
if strings.TrimSpace(s) == "" {
return nil
}
return &s
}
b, err := json.Marshal(v)
if err != nil {
return nil
}
t := string(b)
return &t
}
func syncCategories(ctx context.Context, db *gorm.DB, post *models.Post, data PostFile, out bonfire.Output) error {
if len(data.Categories) == 0 {
return nil
}
var cats []models.Category
for _, slug := range data.Categories {
slug = strings.TrimSpace(slug)
if slug == "" {
continue
}
var cat models.Category
err := db.Where("slug = ?", slug).Take(&cat).Error
if err == gorm.ErrRecordNotFound {
name := data.CategoryNames[slug]
if name == "" {
name = titleFromSlug(slug)
}
cat = models.Category{Name: name, Slug: slug}
if err := db.Create(&cat).Error; err != nil {
return err
}
if out != nil {
out.Info(" Created category: " + slug)
}
} else if err != nil {
return err
}
cats = append(cats, cat)
}
if len(cats) == 0 {
return nil
}
return db.WithContext(ctx).Model(post).Association("Categories").Replace(cats)
}
func titleFromSlug(slug string) string {
parts := strings.Split(strings.ReplaceAll(slug, "-", " "), " ")
for i, p := range parts {
if p == "" {
continue
}
r, size := utf8.DecodeRuneInString(p)
parts[i] = string(unicode.ToUpper(r)) + p[size:]
}
return strings.Join(parts, " ")
}
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, translations map[string]map[string]any) error {
if len(translations) == 0 {
return nil
}
fields := []string{"title", "slug", "content", "content_html", "excerpt", "metadata"}
for locale, attrs := range translations {
for _, field := range fields {
val, ok := attrs[field]
if !ok || val == nil {
continue
}
if field == "title" {
if s, isStr := val.(string); isStr {
val = stripUnpublishedTitlePrefix(s)
}
}
if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil {
return err
}
}
}
return nil
}
func stripUnpublishedTitlePrefix(title string) string {
for strings.HasPrefix(title, UnpublishedTitlePrefix) {
title = strings.TrimPrefix(title, UnpublishedTitlePrefix)
}
return title
}
func IOPayloadPath(raw []byte, fallback string) (path string, dry, force bool) {
path = fallback
if len(raw) == 0 {
return path, false, false
}
var body struct {
Path string `json:"path"`
DryRun bool `json:"dry_run"`
Force bool `json:"force"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return path, false, false
}
if strings.TrimSpace(body.Path) != "" {
path = strings.TrimSpace(body.Path)
}
return path, body.DryRun, body.Force
}

View File

@@ -28,6 +28,7 @@ func init() {
var _ pact.FormBeforeCreate = postsAdminController{}
var _ pact.FormBeforeUpdate = postsAdminController{}
var _ pact.FormBeforeDelete = postsAdminController{}
var _ pact.HasAdminActions = postsAdminController{}
var _ cabana.FieldRelationProvider = postsAdminController{}
var _ pact.AdminController = categoriesAdminController{}

View File

@@ -3,9 +3,11 @@ package controllers
import (
"context"
"errors"
"fmt"
"strconv"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/console"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
@@ -24,6 +26,9 @@ const (
PermissionAccessOtherPosts = "golem15.journal.access_other_posts"
// PermissionAccessPublish is required to persist published=true or published_at.
PermissionAccessPublish = "golem15.journal.access_publish"
// PermissionAccessImportExport gates journal:export-posts / import-posts
// toolbar actions (D-13).
PermissionAccessImportExport = "golem15.journal.access_import_export"
)
type postsAdminController struct {
@@ -215,6 +220,69 @@ func preparePostWrite(ctx context.Context, post *models.Post) error {
return nil
}
func (c postsAdminController) AdminActions() []pact.AdminAction {
return []pact.AdminAction{
{
Name: "exportPosts",
Label: "golem15.journal::lang.posts.export_post",
Permissions: []string{PermissionAccessImportExport},
Run: c.runExportPosts,
},
{
Name: "importPosts",
Label: "golem15.journal::lang.posts.import_post",
Permissions: []string{PermissionAccessImportExport},
Run: c.runImportPosts,
},
}
}
func (c postsAdminController) runExportPosts(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
path, dry, _ := console.IOPayloadPath(in.Payload, "journal-export")
n, attachments, err := console.ExportToDir(ctx, c.db(ctx), path, dry, nil)
if err != nil {
return pact.AdminActionResult{}, err
}
msg := fmt.Sprintf("Exported %d posts to: %s", n, path)
if dry {
msg = fmt.Sprintf("Would export %d posts to: %s", n, path)
}
if attachments {
msg += " File attachments are not included."
}
return pact.AdminActionResult{
Message: msg,
Data: map[string]any{"count": n, "path": path, "dry_run": dry},
}, nil
}
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 {
return refuseUnpublishedWrite(ctx, post)
})
if err != nil {
return pact.AdminActionResult{}, err
}
msg := fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed",
stats.Created, stats.Updated, stats.Skipped, stats.Failed)
if dry {
msg = "Dry run complete (no changes made). " + msg
}
return pact.AdminActionResult{
Message: msg,
Data: map[string]any{
"created": stats.Created,
"updated": stats.Updated,
"skipped": stats.Skipped,
"failed": stats.Failed,
"path": path,
"dry_run": dry,
"force": force,
},
}, nil
}
func refuseUnpublishedWrite(ctx context.Context, post *models.Post) error {
if !post.Published && post.PublishedAt == nil {
return nil

View File

@@ -14,7 +14,7 @@ defaultSort:
direction: desc
toolbar:
buttons: [create, delete]
buttons: [create, delete, exportPosts, importPosts]
search:
prompt: backend::lang.list.search_prompt

View File

@@ -4,9 +4,11 @@ import (
"embed"
"io/fs"
"git.golem15.com/golem15/sm-journal-plugin/console"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/sm-journal-plugin/updates"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/go-gormigrate/gormigrate/v2"
@@ -17,6 +19,7 @@ var (
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
_ pact.HasCommands = (*Plugin)(nil)
)
//go:embed lang
@@ -47,6 +50,13 @@ func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
func (p *Plugin) Models() []any { return models.All() }
func (p *Plugin) Commands() []bonfire.Command {
return []bonfire.Command{
console.ExportPosts(p.app),
console.ImportPosts(p.app),
}
}
func init() {
party.Register(&Plugin{})
}

View File

@@ -1,6 +1,11 @@
package journal
import "testing"
import (
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
func TestPostTranslatableSmoke(t *testing.T) {
p := &Plugin{}
@@ -12,3 +17,38 @@ func TestPostTranslatableSmoke(t *testing.T) {
t.Fatalf("Requires = %#v, want exactly [golem15.translate]", got)
}
}
func TestJournalCommands(t *testing.T) {
p := &Plugin{}
names := map[string]bool{}
for _, cmd := range p.Commands() {
names[cmd.Name] = true
}
for _, want := range []string{"journal:export-posts", "journal:import-posts"} {
if !names[want] {
t.Fatalf("Commands() missing %s", want)
}
}
foundExport, foundImport := false, false
for _, ctl := range p.AdminControllers() {
src, ok := ctl.(pact.HasAdminActions)
if !ok {
continue
}
for _, action := range src.AdminActions() {
perm := strings.Join(action.Permissions, ",")
if !strings.Contains(perm, "golem15.journal.access_import_export") {
continue
}
switch action.Name {
case "exportPosts":
foundExport = true
case "importPosts":
foundImport = true
}
}
}
if !foundExport || !foundImport {
t.Fatalf("Posts AdminActions missing import/export with access_import_export (export=%v import=%v)", foundExport, foundImport)
}
}