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>
This commit is contained in:
23
README.md
23
README.md
@@ -18,10 +18,11 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
|
||||
|
||||
## Features
|
||||
|
||||
- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName.
|
||||
- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`/`mlmarkdown`. `classes.FormatHTML` regenerates `content_html` on admin save.
|
||||
- **Categories and tags.** `models.Category` is translatable (name, description, slug). Fillable is name, slug, code, description, parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`.
|
||||
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false.
|
||||
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`.
|
||||
- **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`.
|
||||
- **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON.
|
||||
- **Schema.** Seven gormigrate steps create the six journal tables and the author-slug column. AutoMigrate is not used. Table names are `golem15_journal_*` only.
|
||||
|
||||
@@ -85,6 +86,7 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
|
||||
| `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. |
|
||||
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
|
||||
| `journal.Plugin.Models` | Models registered by this plugin. |
|
||||
| `journal.Plugin.Commands` | Registers `journal:export-posts` and `journal:import-posts`. |
|
||||
| `models.Post` | Post row; `TableName` is `golem15_journal_posts`. |
|
||||
| `models.Post.MorphName` | PHP class string `Golem15\Journal\Models\Post`. |
|
||||
| `models.Post.Fillable` | Allow-list excluding `redactor_id`, `content_html`, and `user_id`. |
|
||||
@@ -98,11 +100,26 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
|
||||
| `models.All` | Models registered by this plugin. |
|
||||
| `updates.All` | gormigrate steps registered by this plugin. |
|
||||
|
||||
## CLI commands
|
||||
|
||||
Both commands write JSON files (one per post slug), matching the PHP `journal:export-posts` / `journal:import-posts` tools. They are not a CSV ImportExport framework. A blog application binary registers them through `Plugin.Commands`.
|
||||
|
||||
| Command | Arguments and flags | Effect |
|
||||
|---------|---------------------|--------|
|
||||
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations. Attachments are omitted. |
|
||||
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files. Existing slugs are skipped unless `--force` updates them. |
|
||||
|
||||
```sh
|
||||
./bin/acme journal:export-posts --path journal-export
|
||||
./bin/acme journal:import-posts --path journal-export --dry-run
|
||||
./bin/acme journal:import-posts --path journal-export --force
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: backpack, cabana, lagoon/attach, pact, party.
|
||||
- SummerCMS modules: backpack, bonfire, cabana, lagoon/attach, pact, party.
|
||||
- Compiled plugin: `golem15.translate`.
|
||||
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`. Test-only: `github.com/testcontainers/testcontainers-go`.
|
||||
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`. Test-only: `github.com/testcontainers/testcontainers-go`.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
19
console/columns.go
Normal file
19
console/columns.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package console
|
||||
|
||||
// ExportColumns is the Winter PostExport column set (table columns plus
|
||||
// author_email, categories, featured_image_urls). The JSON CLI writes the
|
||||
// ExportPosts.php document, not a CSV row; these names are the shared field
|
||||
// list toolbar and CLI both read.
|
||||
var ExportColumns = []string{
|
||||
"id", "title", "slug", "excerpt", "content", "content_html",
|
||||
"published", "published_at", "created_at", "updated_at",
|
||||
"metadata", "sources", "author_email", "categories", "featured_image_urls",
|
||||
}
|
||||
|
||||
// ImportColumns is the Winter PostImport column set. title, content, and slug
|
||||
// are required. id, categories, and author_email are lookup keys, not host
|
||||
// fillable columns.
|
||||
var ImportColumns = []string{
|
||||
"id", "title", "slug", "excerpt", "content",
|
||||
"published", "published_at", "categories", "author_email",
|
||||
}
|
||||
253
console/export_posts.go
Normal file
253
console/export_posts.go
Normal file
@@ -0,0 +1,253 @@
|
||||
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...))
|
||||
}
|
||||
357
console/import_posts.go
Normal file
357
console/import_posts.go
Normal file
@@ -0,0 +1,357 @@
|
||||
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
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func init() {
|
||||
var _ pact.FormBeforeCreate = postsAdminController{}
|
||||
var _ pact.FormBeforeUpdate = postsAdminController{}
|
||||
var _ pact.FormBeforeDelete = postsAdminController{}
|
||||
var _ pact.HasAdminActions = postsAdminController{}
|
||||
var _ cabana.FieldRelationProvider = postsAdminController{}
|
||||
|
||||
var _ pact.AdminController = categoriesAdminController{}
|
||||
|
||||
@@ -3,9 +3,11 @@ package controllers
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
@@ -24,6 +26,9 @@ const (
|
||||
PermissionAccessOtherPosts = "golem15.journal.access_other_posts"
|
||||
// PermissionAccessPublish is required to persist published=true or published_at.
|
||||
PermissionAccessPublish = "golem15.journal.access_publish"
|
||||
// PermissionAccessImportExport gates journal:export-posts / import-posts
|
||||
// toolbar actions (D-13).
|
||||
PermissionAccessImportExport = "golem15.journal.access_import_export"
|
||||
)
|
||||
|
||||
type postsAdminController struct {
|
||||
@@ -215,6 +220,69 @@ func preparePostWrite(ctx context.Context, post *models.Post) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c postsAdminController) AdminActions() []pact.AdminAction {
|
||||
return []pact.AdminAction{
|
||||
{
|
||||
Name: "exportPosts",
|
||||
Label: "golem15.journal::lang.posts.export_post",
|
||||
Permissions: []string{PermissionAccessImportExport},
|
||||
Run: c.runExportPosts,
|
||||
},
|
||||
{
|
||||
Name: "importPosts",
|
||||
Label: "golem15.journal::lang.posts.import_post",
|
||||
Permissions: []string{PermissionAccessImportExport},
|
||||
Run: c.runImportPosts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c postsAdminController) runExportPosts(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||
path, dry, _ := console.IOPayloadPath(in.Payload, "journal-export")
|
||||
n, attachments, err := console.ExportToDir(ctx, c.db(ctx), path, dry, nil)
|
||||
if err != nil {
|
||||
return pact.AdminActionResult{}, err
|
||||
}
|
||||
msg := fmt.Sprintf("Exported %d posts to: %s", n, path)
|
||||
if dry {
|
||||
msg = fmt.Sprintf("Would export %d posts to: %s", n, path)
|
||||
}
|
||||
if attachments {
|
||||
msg += " File attachments are not included."
|
||||
}
|
||||
return pact.AdminActionResult{
|
||||
Message: msg,
|
||||
Data: map[string]any{"count": n, "path": path, "dry_run": dry},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c postsAdminController) runImportPosts(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||
path, dry, force := console.IOPayloadPath(in.Payload, "journal-export")
|
||||
stats, err := console.ImportFromDir(ctx, c.db(ctx), path, force, dry, nil, func(post *models.Post) error {
|
||||
return refuseUnpublishedWrite(ctx, post)
|
||||
})
|
||||
if err != nil {
|
||||
return pact.AdminActionResult{}, err
|
||||
}
|
||||
msg := fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed",
|
||||
stats.Created, stats.Updated, stats.Skipped, stats.Failed)
|
||||
if dry {
|
||||
msg = "Dry run complete (no changes made). " + msg
|
||||
}
|
||||
return pact.AdminActionResult{
|
||||
Message: msg,
|
||||
Data: map[string]any{
|
||||
"created": stats.Created,
|
||||
"updated": stats.Updated,
|
||||
"skipped": stats.Skipped,
|
||||
"failed": stats.Failed,
|
||||
"path": path,
|
||||
"dry_run": dry,
|
||||
"force": force,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func refuseUnpublishedWrite(ctx context.Context, post *models.Post) error {
|
||||
if !post.Published && post.PublishedAt == nil {
|
||||
return nil
|
||||
|
||||
@@ -14,7 +14,7 @@ defaultSort:
|
||||
direction: desc
|
||||
|
||||
toolbar:
|
||||
buttons: [create, delete]
|
||||
buttons: [create, delete, exportPosts, importPosts]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
|
||||
10
plugin.go
10
plugin.go
@@ -4,9 +4,11 @@ import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/updates"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
@@ -17,6 +19,7 @@ var (
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
_ pact.HasCommands = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
//go:embed lang
|
||||
@@ -47,6 +50,13 @@ func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||
|
||||
func (p *Plugin) Models() []any { return models.All() }
|
||||
|
||||
func (p *Plugin) Commands() []bonfire.Command {
|
||||
return []bonfire.Command{
|
||||
console.ExportPosts(p.app),
|
||||
console.ImportPosts(p.app),
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
party.Register(&Plugin{})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package journal
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
func TestPostTranslatableSmoke(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
@@ -12,3 +17,38 @@ func TestPostTranslatableSmoke(t *testing.T) {
|
||||
t.Fatalf("Requires = %#v, want exactly [golem15.translate]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalCommands(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
names := map[string]bool{}
|
||||
for _, cmd := range p.Commands() {
|
||||
names[cmd.Name] = true
|
||||
}
|
||||
for _, want := range []string{"journal:export-posts", "journal:import-posts"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("Commands() missing %s", want)
|
||||
}
|
||||
}
|
||||
foundExport, foundImport := false, false
|
||||
for _, ctl := range p.AdminControllers() {
|
||||
src, ok := ctl.(pact.HasAdminActions)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, action := range src.AdminActions() {
|
||||
perm := strings.Join(action.Permissions, ",")
|
||||
if !strings.Contains(perm, "golem15.journal.access_import_export") {
|
||||
continue
|
||||
}
|
||||
switch action.Name {
|
||||
case "exportPosts":
|
||||
foundExport = true
|
||||
case "importPosts":
|
||||
foundImport = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundExport || !foundImport {
|
||||
t.Fatalf("Posts AdminActions missing import/export with access_import_export (export=%v import=%v)", foundExport, foundImport)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user