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 }