Files
sm-journal-plugin/console/export_posts.go
Jakub Zych 4ff98f7de8 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>
2026-10-06 18:47:53 +02:00

254 lines
6.8 KiB
Go

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...))
}