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:
253
console/export_posts.go
Normal file
253
console/export_posts.go
Normal 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...))
|
||||
}
|
||||
Reference in New Issue
Block a user