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>
254 lines
6.8 KiB
Go
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...))
|
|
}
|