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>
358 lines
9.0 KiB
Go
358 lines
9.0 KiB
Go
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
|
|
}
|