Files
sm-journal-plugin/console/import_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

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
}