From 4ff98f7de85a6404987373ad5630b17fc5542ade Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 18:47:53 +0200 Subject: [PATCH] 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 --- README.md | 23 +- console/columns.go | 19 ++ console/export_posts.go | 253 ++++++++++++++++++++ console/import_posts.go | 357 +++++++++++++++++++++++++++++ controllers/admin_registry.go | 1 + controllers/posts.go | 68 ++++++ controllers/posts/config_list.yaml | 2 +- plugin.go | 10 + plugin_test.go | 42 +++- 9 files changed, 770 insertions(+), 5 deletions(-) create mode 100644 console/columns.go create mode 100644 console/export_posts.go create mode 100644 console/import_posts.go diff --git a/README.md b/README.md index ac799fc..1de6ea3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/console/columns.go b/console/columns.go new file mode 100644 index 0000000..2b67905 --- /dev/null +++ b/console/columns.go @@ -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", +} diff --git a/console/export_posts.go b/console/export_posts.go new file mode 100644 index 0000000..655a05b --- /dev/null +++ b/console/export_posts.go @@ -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...)) +} diff --git a/console/import_posts.go b/console/import_posts.go new file mode 100644 index 0000000..0e04446 --- /dev/null +++ b/console/import_posts.go @@ -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 +} diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index 2213143..12650bc 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -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{} diff --git a/controllers/posts.go b/controllers/posts.go index 39a454b..bcd1a20 100644 --- a/controllers/posts.go +++ b/controllers/posts.go @@ -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 diff --git a/controllers/posts/config_list.yaml b/controllers/posts/config_list.yaml index fffe8dc..e9690b7 100644 --- a/controllers/posts/config_list.yaml +++ b/controllers/posts/config_list.yaml @@ -14,7 +14,7 @@ defaultSort: direction: desc toolbar: - buttons: [create, delete] + buttons: [create, delete, exportPosts, importPosts] search: prompt: backend::lang.list.search_prompt diff --git a/plugin.go b/plugin.go index 35b5a73..73cf1c9 100644 --- a/plugin.go +++ b/plugin.go @@ -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{}) } diff --git a/plugin_test.go b/plugin_test.go index 75d6184..626be34 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -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) + } +}