A translated content_html sent through the Journal API (syncTranslations
formatted only when content_html was empty) or through
journal:import-posts (no check) was stored verbatim and served by the
anonymous GET /_journal/api/v1/posts and /posts/{slug}. A translation
keyed on the default locale also wrote the host content_html column
past FormatHTML.
classes.FormatTranslation now sets content_html for every translated
locale before it is stored: rendered from a non-blank content with
FormatHTML (host-column parity; a supplied content_html is replaced),
otherwise a supplied content_html is sanitized with the new
classes.SanitizeHTML, which FormatHTML reuses. Both write paths call it.
PHP parity note: Winter's PostApiController::syncTranslations called
Post::formatHtml only when content_html was empty and otherwise stored
the supplied content_html verbatim; ImportPosts::syncTranslations stored
it verbatim; the MLJournalMarkdown admin widget always derived it from
content via formatHtml. The Go port now follows the admin widget on
every path. Response shapes and status codes for valid input are
unchanged; only the stored content_html of unsafe or content-mismatched
translations differs.
363 lines
9.2 KiB
Go
363 lines
9.2 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 {
|
|
// A translated content_html is derived from content or sanitized,
|
|
// as the host column is; Winter's ImportPosts stored it verbatim.
|
|
if err := classes.FormatTranslation(attrs); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|