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