feat(17-04): menu:import-winter with the D-05 type map and read-only export SQL
- link to url, blog_categories to all-journal-categories, --map old=new on top; page entries dropped - entries of unknown types or stale registry references dropped with their subtree, one report line per dropped or remapped entry - ids, menu_id, parent_id, nest columns and sort_order kept; setval for both tables; --force, --dry-run, empty files copy nothing - scripts/winter-export-menus.sql and winter-export-entries.sql - TestMenuImportSmoke
This commit is contained in:
179
console/helpers.go
Normal file
179
console/helpers.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Package console holds the menu plugin's commands: menu:import-winter, a copy
|
||||
// of a WinterCMS site's menus and their entries.
|
||||
package console
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func flagValue(in bonfire.Input, name string) string {
|
||||
v, ok := in.Flag(name)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func flagOn(in bonfire.Input, name string) bool {
|
||||
switch strings.ToLower(flagValue(in, name)) {
|
||||
case "true", "1", "yes":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// lookupDB returns the app database. The generated main.go publishes no
|
||||
// database for plugin commands, so when none is published it opens
|
||||
// database.dsn and publishes it the way lagoon's migrate does. The returned
|
||||
// func closes a pool this call opened; it is a no-op otherwise.
|
||||
func lookupDB(ctx context.Context, app *backpack.App) (*gorm.DB, func(), error) {
|
||||
if app == nil {
|
||||
return nil, nil, fmt.Errorf("golem15.menu: app is nil")
|
||||
}
|
||||
if db, ok := app.Lookup[*gorm.DB](); ok && db != nil {
|
||||
return db, func() {}, nil
|
||||
}
|
||||
if lagoon.DSN(app.Config) == "" {
|
||||
return nil, nil, fmt.Errorf("golem15.menu: database is not configured (set SUMMER_DATABASE__DSN)")
|
||||
}
|
||||
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return gdb, func() { _ = sqlDB.Close() }, nil
|
||||
}
|
||||
|
||||
// readJSONLines decodes one JSON object per non-blank line of file.
|
||||
func readJSONLines(file string) ([]map[string]json.RawMessage, error) {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("File not found: %s", file)
|
||||
}
|
||||
defer f.Close()
|
||||
var rows []map[string]json.RawMessage
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1<<20), 64<<20)
|
||||
n := 0
|
||||
for scanner.Scan() {
|
||||
n++
|
||||
line := bytes.TrimSpace(scanner.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var row map[string]json.RawMessage
|
||||
if err := json.Unmarshal(line, &row); err != nil {
|
||||
return nil, fmt.Errorf("%s line %d: %w", file, n, err)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// isNull reports a missing or null JSON value.
|
||||
func isNull(raw json.RawMessage) bool {
|
||||
t := bytes.TrimSpace(raw)
|
||||
return len(t) == 0 || bytes.Equal(t, []byte("null"))
|
||||
}
|
||||
|
||||
// jsonText reads a string, number or boolean as text; nil for null.
|
||||
func jsonText(raw json.RawMessage) (*string, error) {
|
||||
if isNull(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s string
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
s = x
|
||||
case float64:
|
||||
s = strconv.FormatFloat(x, 'f', -1, 64)
|
||||
case bool:
|
||||
s = strconv.FormatBool(x)
|
||||
default:
|
||||
return nil, fmt.Errorf("want text, got %s", raw)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// jsonUint reads a positive integer given as a number or a string; 0 for
|
||||
// null.
|
||||
func jsonUint(raw json.RawMessage) (uint, error) {
|
||||
s, err := jsonText(raw)
|
||||
if err != nil || s == nil || strings.TrimSpace(*s) == "" {
|
||||
return 0, err
|
||||
}
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(*s), 10, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("want an id, got %s", raw)
|
||||
}
|
||||
return uint(n), nil
|
||||
}
|
||||
|
||||
// jsonBool reads MySQL's 0 and 1, a JSON boolean or "true"/"false"; def for
|
||||
// null.
|
||||
func jsonBool(raw json.RawMessage, def bool) (bool, error) {
|
||||
s, err := jsonText(raw)
|
||||
if err != nil || s == nil {
|
||||
return def, err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(*s)) {
|
||||
case "1", "true":
|
||||
return true, nil
|
||||
case "0", "false", "":
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("want a boolean, got %s", raw)
|
||||
}
|
||||
|
||||
// jsonTime reads a MySQL DATETIME text (with or without fractions) or an
|
||||
// RFC 3339 time as UTC; nil for null.
|
||||
func jsonTime(raw json.RawMessage) (*time.Time, error) {
|
||||
s, err := jsonText(raw)
|
||||
if err != nil || s == nil || strings.TrimSpace(*s) == "" {
|
||||
return nil, err
|
||||
}
|
||||
text := strings.TrimSpace(*s)
|
||||
for _, layout := range []string{"2006-01-02 15:04:05.999999", "2006-01-02 15:04:05", time.RFC3339Nano} {
|
||||
if t, err := time.ParseInLocation(layout, text, time.UTC); err == nil {
|
||||
t = t.UTC()
|
||||
return &t, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("want a date and time, got %s", raw)
|
||||
}
|
||||
|
||||
// jsonInt reads an integer given as a number or a string; nil for null.
|
||||
func jsonInt(raw json.RawMessage) (*int, error) {
|
||||
s, err := jsonText(raw)
|
||||
if err != nil || s == nil || strings.TrimSpace(*s) == "" {
|
||||
return nil, err
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(*s))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("want an integer, got %s", raw)
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
433
console/import_winter.go
Normal file
433
console/import_winter.go
Normal file
@@ -0,0 +1,433 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DefaultTypeMap maps the WinterCMS entry types to this plugin's (D-05): the
|
||||
// PHP link is the built-in url, and the PHP Active Blog Categories list is
|
||||
// the Journal's all-journal-categories registry type. --map pairs extend
|
||||
// and override it. PHP page entries (Winter.Pages) are dropped.
|
||||
var DefaultTypeMap = map[string]string{
|
||||
"link": models.TypeURL,
|
||||
"blog_categories": "all-journal-categories",
|
||||
}
|
||||
|
||||
// droppedPHPType is the Winter.Pages entry type the port does not support.
|
||||
const droppedPHPType = "page"
|
||||
|
||||
// WinterOptions are the inputs of menu:import-winter. MenusFile and
|
||||
// EntriesFile are the outputs of scripts/winter-export-menus.sql and
|
||||
// scripts/winter-export-entries.sql (one JSON object per line). Maps rename
|
||||
// entry types (old to new) over DefaultTypeMap; a type mapped to "" is
|
||||
// dropped. Force replaces the menus and entries the tables already hold;
|
||||
// DryRun reports without writing.
|
||||
type WinterOptions struct {
|
||||
MenusFile string
|
||||
EntriesFile string
|
||||
Maps map[string]string
|
||||
Force bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// WinterReport is what menu:import-winter copied (or would copy on a dry
|
||||
// run): menus and entries kept, entries remapped and dropped, and one report
|
||||
// line per remapped or dropped entry.
|
||||
type WinterReport struct {
|
||||
Menus int
|
||||
Entries int
|
||||
Remapped int
|
||||
Dropped int
|
||||
Lines []string
|
||||
}
|
||||
|
||||
// ImportWinter is menu:import-winter: it copies a WinterCMS site's menus and
|
||||
// menu entries, keeping their ids, menu_id and parent_id, the nest columns
|
||||
// and sort_order. Entry types are mapped (link to url, blog_categories to
|
||||
// all-journal-categories, --map old=new on top); a page entry, an entry of a
|
||||
// type that is neither built-in nor registered, and a registry entry whose
|
||||
// reference is not among the type's current references are dropped with
|
||||
// their subtree, and every dropped or remapped entry is reported. Tables
|
||||
// that already hold rows are refused without --force, which replaces them in
|
||||
// the same transaction. Both id sequences are moved past the copied ids.
|
||||
func ImportWinter(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "menu:import-winter",
|
||||
Description: "Copy a WinterCMS menu and menu entry export into this application",
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "menus", Description: "Menus export (JSON lines from scripts/winter-export-menus.sql)"},
|
||||
{Name: "entries", Description: "Menu entries export (JSON lines from scripts/winter-export-entries.sql)"},
|
||||
{Name: "map", Description: "Rename an entry type, old=new (repeatable; new may be empty to drop the type)", Repeatable: true},
|
||||
{Name: "force", Description: "Replace the menus and entries the tables already hold", Bare: true},
|
||||
{Name: "dry-run", Description: "Report what would be copied without writing", Bare: true},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
maps, err := parseMaps(in.Flags("map"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts := WinterOptions{
|
||||
MenusFile: flagValue(in, "menus"),
|
||||
EntriesFile: flagValue(in, "entries"),
|
||||
Maps: maps,
|
||||
Force: flagOn(in, "force"),
|
||||
DryRun: flagOn(in, "dry-run"),
|
||||
}
|
||||
db, done, err := lookupDB(ctx, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer done()
|
||||
var reg *signpost.Registry
|
||||
if app != nil {
|
||||
reg, _ = app.Lookup[*signpost.Registry]()
|
||||
}
|
||||
_, err = ImportWinterFiles(ctx, db, reg, opts, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parseMaps reads --map old=new values.
|
||||
func parseMaps(values []string) (map[string]string, error) {
|
||||
maps := map[string]string{}
|
||||
for _, v := range values {
|
||||
from, to, ok := strings.Cut(v, "=")
|
||||
from, to = strings.TrimSpace(from), strings.TrimSpace(to)
|
||||
if !ok || from == "" {
|
||||
return nil, fmt.Errorf("--map %q: use old=new", v)
|
||||
}
|
||||
maps[from] = to
|
||||
}
|
||||
return maps, nil
|
||||
}
|
||||
|
||||
// ImportWinterFiles runs menu:import-winter against db; reg is the menu item
|
||||
// type registry the entry types are checked against (nil knows none).
|
||||
func ImportWinterFiles(ctx context.Context, db *gorm.DB, reg *signpost.Registry, opts WinterOptions, out bonfire.Output) (WinterReport, error) {
|
||||
var report WinterReport
|
||||
if opts.MenusFile == "" || opts.EntriesFile == "" {
|
||||
return report, errors.New("give --menus and --entries")
|
||||
}
|
||||
menuRows, err := readJSONLines(opts.MenusFile)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
entryRows, err := readJSONLines(opts.EntriesFile)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if len(menuRows) == 0 && len(entryRows) == 0 {
|
||||
out.Info("The export holds no menus and no entries; nothing to copy (0 menus, 0 entries).")
|
||||
return report, nil
|
||||
}
|
||||
menus := make([]models.Menu, 0, len(menuRows))
|
||||
menuIDs := map[uint]bool{}
|
||||
for i, row := range menuRows {
|
||||
m, err := decodeMenu(row)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("%s row %d: %w", opts.MenusFile, i+1, err)
|
||||
}
|
||||
menus = append(menus, m)
|
||||
menuIDs[m.ID] = true
|
||||
}
|
||||
entries := make([]models.MenuEntry, 0, len(entryRows))
|
||||
for i, row := range entryRows {
|
||||
e, err := decodeEntry(row)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("%s row %d: %w", opts.EntriesFile, i+1, err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
kept, err := mapEntries(ctx, reg, entries, menuIDs, mergedMaps(opts.Maps), &report)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.Menus, report.Entries = len(menus), len(kept)
|
||||
for _, line := range report.Lines {
|
||||
out.Warning(line)
|
||||
}
|
||||
|
||||
db = db.WithContext(ctx)
|
||||
var haveMenus, haveEntries int64
|
||||
if err := db.Model(&models.Menu{}).Count(&haveMenus).Error; err != nil {
|
||||
return report, err
|
||||
}
|
||||
if err := db.Model(&models.MenuEntry{}).Count(&haveEntries).Error; err != nil {
|
||||
return report, err
|
||||
}
|
||||
if (haveMenus > 0 || haveEntries > 0) && !opts.Force {
|
||||
msg := fmt.Sprintf("The menu tables already hold %d menus and %d entries; rerun with --force to replace them.", haveMenus, haveEntries)
|
||||
if !opts.DryRun {
|
||||
return report, errors.New(msg)
|
||||
}
|
||||
out.Warning(msg)
|
||||
}
|
||||
summary := fmt.Sprintf("%d menus, %d entries kept, %d remapped, %d dropped.", report.Menus, report.Entries, report.Remapped, report.Dropped)
|
||||
if opts.DryRun {
|
||||
out.Info("Dry run: " + summary)
|
||||
out.Info("Dry run complete (no changes made).")
|
||||
return report, nil
|
||||
}
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
if haveMenus > 0 || haveEntries > 0 {
|
||||
for _, stmt := range []string{`DELETE FROM golem15_menu_menu_entries`, `DELETE FROM golem15_menu_menus`} {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// The rows are copied as they are: the entry save hook (sort_order,
|
||||
// menu from the parent) is for admin saves.
|
||||
raw := tx.Session(&gorm.Session{SkipHooks: true})
|
||||
for i := range menus {
|
||||
if err := raw.Create(&menus[i]).Error; err != nil {
|
||||
return fmt.Errorf("menu %d: %w", menus[i].ID, err)
|
||||
}
|
||||
}
|
||||
for i := range kept {
|
||||
if err := raw.Create(&kept[i]).Error; err != nil {
|
||||
return fmt.Errorf("entry %d: %w", kept[i].ID, err)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"golem15_menu_menus", "golem15_menu_menu_entries"} {
|
||||
if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('` + table + `', 'id'), COALESCE(MAX(id), 1), MAX(id) IS NOT NULL) FROM ` + table).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
out.Success("Copied " + summary)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// mergedMaps is DefaultTypeMap with the --map pairs on top.
|
||||
func mergedMaps(maps map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(DefaultTypeMap)+len(maps))
|
||||
for k, v := range DefaultTypeMap {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range maps {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mapEntries maps every entry's type and returns the entries to copy, in
|
||||
// export order. An entry is dropped when its menu is not in the menus
|
||||
// export, when its parent is missing or dropped (with the whole subtree),
|
||||
// for a page type, for a type mapped to nothing, for a type that is neither
|
||||
// built-in nor registered, and for a registry type that takes references
|
||||
// when its reference is not among them.
|
||||
func mapEntries(ctx context.Context, reg *signpost.Registry, entries []models.MenuEntry, menuIDs map[uint]bool, maps map[string]string, report *WinterReport) ([]models.MenuEntry, error) {
|
||||
byID := make(map[uint]*models.MenuEntry, len(entries))
|
||||
for i := range entries {
|
||||
byID[entries[i].ID] = &entries[i]
|
||||
}
|
||||
refs := map[string][]string{}
|
||||
// reason is why an entry is dropped on its own, "" when it is kept;
|
||||
// oldType is the exported type the report names.
|
||||
reason := map[uint]string{}
|
||||
oldType := make(map[uint]string, len(entries))
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
old := e.Type
|
||||
oldType[e.ID] = old
|
||||
label := describe(e, old)
|
||||
if to, ok := maps[old]; ok && to != old {
|
||||
if to == "" {
|
||||
reason[e.ID] = fmt.Sprintf("type '%s' is mapped to nothing", old)
|
||||
continue
|
||||
}
|
||||
e.Type = to
|
||||
report.Lines = append(report.Lines, fmt.Sprintf("%s: type '%s' mapped to '%s'", label, old, to))
|
||||
report.Remapped++
|
||||
}
|
||||
switch {
|
||||
case !menuIDs[e.MenuID]:
|
||||
reason[e.ID] = fmt.Sprintf("menu %d is not in the menus export", e.MenuID)
|
||||
case e.Type == droppedPHPType:
|
||||
reason[e.ID] = "page entries (Winter.Pages) are not supported"
|
||||
case models.Builtin(e.Type):
|
||||
default:
|
||||
if _, known := reg.Type(e.Type); !known {
|
||||
reason[e.ID] = fmt.Sprintf("type '%s' is neither built-in nor registered; map it with --map %s=<type>", e.Type, old)
|
||||
continue
|
||||
}
|
||||
values, cached := refs[e.Type]
|
||||
if !cached {
|
||||
set, err := signpost.ReferenceOptions(ctx, reg, e.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if set.Applies {
|
||||
values = []string{}
|
||||
for _, o := range set.Options {
|
||||
values = append(values, o.Value)
|
||||
}
|
||||
}
|
||||
refs[e.Type] = values
|
||||
}
|
||||
if values != nil && !slices.Contains(values, models.Text(e.Reference)) {
|
||||
reason[e.ID] = fmt.Sprintf("reference '%s' is not among the current references of '%s'", models.Text(e.Reference), e.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
// dropped walks up the parents: an entry goes when it or an ancestor
|
||||
// goes, or when an ancestor is missing from the export.
|
||||
var dropped func(e *models.MenuEntry, seen map[uint]bool) (bool, string)
|
||||
dropped = func(e *models.MenuEntry, seen map[uint]bool) (bool, string) {
|
||||
if r := reason[e.ID]; r != "" {
|
||||
return true, r
|
||||
}
|
||||
if e.ParentID == nil {
|
||||
return false, ""
|
||||
}
|
||||
if seen[e.ID] {
|
||||
return true, "its parent chain loops"
|
||||
}
|
||||
seen[e.ID] = true
|
||||
parent, ok := byID[*e.ParentID]
|
||||
if !ok {
|
||||
return true, fmt.Sprintf("its parent %d is not in the entries export", *e.ParentID)
|
||||
}
|
||||
if gone, _ := dropped(parent, seen); gone {
|
||||
return true, fmt.Sprintf("dropped with its parent %d", parent.ID)
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
kept := make([]models.MenuEntry, 0, len(entries))
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
if gone, why := dropped(e, map[uint]bool{}); gone {
|
||||
report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (%s)", describe(e, oldType[e.ID]), why))
|
||||
report.Dropped++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, *e)
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// describe names an entry in a report line by id, title, menu, exported
|
||||
// type and reference.
|
||||
func describe(e *models.MenuEntry, typ string) string {
|
||||
s := fmt.Sprintf("entry %d '%s' (menu %d, type '%s'", e.ID, models.Text(e.Title), e.MenuID, typ)
|
||||
if e.Reference != nil && *e.Reference != "" {
|
||||
s += fmt.Sprintf(", reference '%s'", *e.Reference)
|
||||
}
|
||||
return s + ")"
|
||||
}
|
||||
|
||||
// decodeMenu reads one exported golem15_menu_menus row.
|
||||
func decodeMenu(row map[string]json.RawMessage) (models.Menu, error) {
|
||||
var m models.Menu
|
||||
id, err := jsonUint(row["id"])
|
||||
if err != nil || id == 0 {
|
||||
return m, fmt.Errorf("id: want a positive id, got %s", row["id"])
|
||||
}
|
||||
m.ID = id
|
||||
name, err := jsonText(row["name"])
|
||||
if err != nil || name == nil || *name == "" {
|
||||
return m, fmt.Errorf("name is missing")
|
||||
}
|
||||
code, err := jsonText(row["code"])
|
||||
if err != nil || code == nil || *code == "" {
|
||||
return m, fmt.Errorf("code is missing")
|
||||
}
|
||||
m.Name, m.Code = *name, *code
|
||||
if m.IsActive, err = jsonBool(row["is_active"], true); err != nil {
|
||||
return m, fmt.Errorf("is_active: %w", err)
|
||||
}
|
||||
if m.CSSClass, err = jsonText(row["css_class"]); err != nil {
|
||||
return m, fmt.Errorf("css_class: %w", err)
|
||||
}
|
||||
if err := decodeTimes(row, &m.CreatedAt, &m.UpdatedAt); err != nil {
|
||||
return m, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// decodeEntry reads one exported golem15_menu_menu_entries row with every
|
||||
// PHP column; NOT NULL columns missing from the export take the PHP
|
||||
// defaults (nest columns 0, an empty title, published).
|
||||
func decodeEntry(row map[string]json.RawMessage) (models.MenuEntry, error) {
|
||||
var e models.MenuEntry
|
||||
id, err := jsonUint(row["id"])
|
||||
if err != nil || id == 0 {
|
||||
return e, fmt.Errorf("id: want a positive id, got %s", row["id"])
|
||||
}
|
||||
e.ID = id
|
||||
if e.MenuID, err = jsonUint(row["menu_id"]); err != nil {
|
||||
return e, fmt.Errorf("menu_id: %w", err)
|
||||
}
|
||||
parent, err := jsonUint(row["parent_id"])
|
||||
if err != nil {
|
||||
return e, fmt.Errorf("parent_id: %w", err)
|
||||
}
|
||||
if parent != 0 {
|
||||
e.ParentID = &parent
|
||||
}
|
||||
for key, dest := range map[string]**int{"nest_left": &e.NestLeft, "nest_right": &e.NestRight, "nest_depth": &e.NestDepth, "sort_order": &e.SortOrder} {
|
||||
n, err := jsonInt(row[key])
|
||||
if err != nil {
|
||||
return e, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
*dest = n
|
||||
}
|
||||
for _, p := range []**int{&e.NestLeft, &e.NestRight, &e.NestDepth} {
|
||||
if *p == nil {
|
||||
zero := 0
|
||||
*p = &zero
|
||||
}
|
||||
}
|
||||
typ, err := jsonText(row["type"])
|
||||
if err != nil || typ == nil || *typ == "" {
|
||||
return e, fmt.Errorf("type is missing")
|
||||
}
|
||||
e.Type = *typ
|
||||
for key, dest := range map[string]**string{"title": &e.Title, "url": &e.URL, "reference": &e.Reference, "code": &e.Code, "icon": &e.Icon, "css_class": &e.CSSClass, "target": &e.Target} {
|
||||
if *dest, err = jsonText(row[key]); err != nil {
|
||||
return e, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
}
|
||||
if e.Title == nil {
|
||||
empty := ""
|
||||
e.Title = &empty
|
||||
}
|
||||
published, err := jsonBool(row["is_published"], true)
|
||||
if err != nil {
|
||||
return e, fmt.Errorf("is_published: %w", err)
|
||||
}
|
||||
e.IsPublished = &published
|
||||
if err := decodeTimes(row, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
return e, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func decodeTimes(row map[string]json.RawMessage, created, updated **time.Time) error {
|
||||
var err error
|
||||
if *created, err = jsonTime(row["created_at"]); err != nil {
|
||||
return fmt.Errorf("created_at: %w", err)
|
||||
}
|
||||
if *updated, err = jsonTime(row["updated_at"]); err != nil {
|
||||
return fmt.Errorf("updated_at: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -16,14 +16,17 @@ import (
|
||||
"time"
|
||||
|
||||
_ "git.golem15.com/golem15/sm-menu-plugin"
|
||||
"git.golem15.com/golem15/sm-menu-plugin/console"
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -34,8 +37,9 @@ const testTypesPluginID = "acme.menutypes"
|
||||
|
||||
const permManage = "golem15.menu.manage_menu"
|
||||
|
||||
// acmeTypes registers acme-page (references 1 Home with 2 About under it)
|
||||
// and acme-list (a list type without references).
|
||||
// acmeTypes registers acme-page (references 1 Home with 2 About under it),
|
||||
// acme-list (a list type without references) and all-journal-categories,
|
||||
// the registry key the Winter import maps blog_categories to.
|
||||
type acmeTypes struct{}
|
||||
|
||||
func (acmeTypes) ID() string { return testTypesPluginID }
|
||||
@@ -72,6 +76,16 @@ func (acmeTypes) MenuItemTypes() []pact.MenuItemType {
|
||||
return &pact.ResolvedMenuItem{Items: items}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "all-journal-categories",
|
||||
Label: "All categories",
|
||||
DefaultPattern: "/:slug",
|
||||
Nesting: true,
|
||||
DynamicItems: true,
|
||||
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
return &pact.ResolvedMenuItem{Items: []pact.ResolvedMenuItem{{Title: "News", URL: "/news", Alternates: []pact.MenuItemAlternate{{Locale: "pl", URL: "/news", Title: "News"}}}}}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,3 +386,161 @@ func TestMenuAdminSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func testOutput() (bonfire.Output, *bytes.Buffer) {
|
||||
var buf bytes.Buffer
|
||||
return bonfire.NewOutput(strings.NewReader(""), &buf, &buf), &buf
|
||||
}
|
||||
|
||||
func (e *menuEnv) counts(t *testing.T) (int64, int64) {
|
||||
t.Helper()
|
||||
var menus, entries int64
|
||||
if err := e.gdb.Model(&models.Menu{}).Count(&menus).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.gdb.Model(&models.MenuEntry{}).Count(&entries).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return menus, entries
|
||||
}
|
||||
|
||||
// winterMenus and winterEntries are a Winter export: two menus; a link with
|
||||
// a divider under it, a page entry with a child, a blog_categories list, a
|
||||
// stale and a current acme-page reference, and a custom type renamed by
|
||||
// --map.
|
||||
const (
|
||||
winterMenus = `{"id": 3, "name": "Main", "code": "main", "is_active": 1, "css_class": "nav", "created_at": "2024-01-01 10:00:00.000000", "updated_at": null}
|
||||
{"id": 7, "name": "Footer", "code": "footer", "is_active": 0, "css_class": null, "created_at": null, "updated_at": null}
|
||||
`
|
||||
winterEntries = `{"id": 10, "menu_id": 3, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "link", "title": "Home", "url": "/", "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 11, "menu_id": 3, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 2, "type": "page", "title": "About", "url": null, "reference": "about", "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 12, "menu_id": 3, "parent_id": 11, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "link", "title": "Team", "url": "/team", "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 13, "menu_id": 3, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 3, "type": "blog_categories", "title": "Blog", "url": "/blog", "reference": null, "code": "blog", "icon": null, "css_class": "drop", "target": "_self", "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 14, "menu_id": 3, "parent_id": 10, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "divider", "title": "", "url": null, "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 0, "created_at": null, "updated_at": null}
|
||||
{"id": 15, "menu_id": 7, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "acme-page", "title": "Gone", "url": null, "reference": "99", "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 16, "menu_id": 7, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 2, "type": "acme-page", "title": "About us", "url": null, "reference": "2", "code": null, "icon": null, "css_class": null, "target": "_blank", "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 17, "menu_id": 7, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 3, "type": "custom_link", "title": "Shop", "url": "https://shop.example.test", "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
|
||||
`
|
||||
)
|
||||
|
||||
// TestMenuImportSmoke copies a Winter export through menu:import-winter:
|
||||
// link becomes url, blog_categories becomes all-journal-categories, a page
|
||||
// entry and its child are dropped and reported, a stale reference is
|
||||
// dropped, ids and parent_id are kept, both sequences move past the copied
|
||||
// ids, a dry run writes nothing, a second copy is refused without --force,
|
||||
// and empty files report zero.
|
||||
func TestMenuImportSmoke(t *testing.T) {
|
||||
e := bootMenu(t)
|
||||
ctx := t.Context()
|
||||
reg, ok := e.app.Lookup[*signpost.Registry]()
|
||||
if !ok {
|
||||
t.Fatal("signpost registry is not published")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
menusFile := writeFile(t, dir, "menus.jsonl", winterMenus)
|
||||
entriesFile := writeFile(t, dir, "entries.jsonl", winterEntries)
|
||||
opts := console.WinterOptions{MenusFile: menusFile, EntriesFile: entriesFile, Maps: map[string]string{"custom_link": "url"}}
|
||||
|
||||
dry := opts
|
||||
dry.DryRun = true
|
||||
out, buf := testOutput()
|
||||
report, err := console.ImportWinterFiles(ctx, e.gdb, reg, dry, out)
|
||||
if err != nil || report.Menus != 2 || report.Entries != 5 || report.Dropped != 3 || report.Remapped != 4 {
|
||||
t.Fatalf("dry run: %+v %v\n%s", report, err, buf.String())
|
||||
}
|
||||
if m, n := e.counts(t); m != 0 || n != 0 {
|
||||
t.Fatalf("dry run wrote %d menus, %d entries", m, n)
|
||||
}
|
||||
|
||||
out, buf = testOutput()
|
||||
report, err = console.ImportWinterFiles(ctx, e.gdb, reg, opts, out)
|
||||
if err != nil || report.Entries != 5 {
|
||||
t.Fatalf("import: %+v %v\n%s", report, err, buf.String())
|
||||
}
|
||||
for _, want := range []string{
|
||||
"entry 10 'Home' (menu 3, type 'link'): type 'link' mapped to 'url'",
|
||||
"entry 11 'About' (menu 3, type 'page', reference 'about'): dropped (page entries (Winter.Pages) are not supported)",
|
||||
"entry 12 'Team' (menu 3, type 'link'): dropped (dropped with its parent 11)",
|
||||
"entry 13 'Blog' (menu 3, type 'blog_categories'): type 'blog_categories' mapped to 'all-journal-categories'",
|
||||
"entry 15 'Gone' (menu 7, type 'acme-page', reference '99'): dropped (reference '99' is not among the current references of 'acme-page')",
|
||||
"entry 17 'Shop' (menu 7, type 'custom_link'): type 'custom_link' mapped to 'url'",
|
||||
} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("report lacks %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
var entries []models.MenuEntry
|
||||
if err := e.gdb.Order("id").Find(&entries).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []uint{}
|
||||
for _, en := range entries {
|
||||
ids = append(ids, en.ID)
|
||||
}
|
||||
if fmt.Sprint(ids) != "[10 13 14 16 17]" {
|
||||
t.Fatalf("copied ids = %v", ids)
|
||||
}
|
||||
if en := entries[0]; en.Type != "url" || en.MenuID != 3 || intOf(en.SortOrder) != 1 {
|
||||
t.Fatalf("entry 10 = %+v", en)
|
||||
}
|
||||
if en := entries[1]; en.Type != "all-journal-categories" || models.Text(en.URL) != "/blog" || models.Text(en.Code) != "blog" || models.Text(en.Target) != "_self" {
|
||||
t.Fatalf("entry 13 = %+v", en)
|
||||
}
|
||||
if en := entries[2]; en.ParentID == nil || *en.ParentID != 10 || en.IsPublished == nil || *en.IsPublished || en.Type != "divider" {
|
||||
t.Fatalf("entry 14 = %+v", en)
|
||||
}
|
||||
if en := entries[3]; en.Type != "acme-page" || models.Text(en.Reference) != "2" || en.MenuID != 7 || en.URLPattern != nil {
|
||||
t.Fatalf("entry 16 = %+v", en)
|
||||
}
|
||||
var footer models.Menu
|
||||
if err := e.gdb.First(&footer, 7).Error; err != nil || footer.IsActive || footer.Code != "footer" {
|
||||
t.Fatalf("menu 7 = %+v %v", footer, err)
|
||||
}
|
||||
|
||||
// Both sequences moved past the copied ids.
|
||||
token := e.admin(t, permManage)
|
||||
if id := e.create(t, token, menusPath, map[string]any{"name": "Side", "code": "side", "is_active": true}); id != 8 {
|
||||
t.Fatalf("new menu id = %d, want 8", id)
|
||||
}
|
||||
if id := e.create(t, token, entriesPath, map[string]any{"menu": 3, "type": "url", "title": "New", "url": "/new"}); id != 18 {
|
||||
t.Fatalf("new entry id = %d, want 18", id)
|
||||
}
|
||||
|
||||
// A second copy is refused without --force and changes nothing; --force
|
||||
// replaces both tables.
|
||||
out, buf = testOutput()
|
||||
if _, err := console.ImportWinterFiles(ctx, e.gdb, reg, opts, out); err == nil || !strings.Contains(err.Error(), "--force") {
|
||||
t.Fatalf("second import: %v\n%s", err, buf.String())
|
||||
}
|
||||
if m, n := e.counts(t); m != 3 || n != 6 {
|
||||
t.Fatalf("refused import changed the tables: %d menus, %d entries", m, n)
|
||||
}
|
||||
forced := opts
|
||||
forced.Force = true
|
||||
out, buf = testOutput()
|
||||
if _, err := console.ImportWinterFiles(ctx, e.gdb, reg, forced, out); err != nil {
|
||||
t.Fatalf("forced import: %v\n%s", err, buf.String())
|
||||
}
|
||||
if m, n := e.counts(t); m != 2 || n != 5 {
|
||||
t.Fatalf("forced import left %d menus, %d entries", m, n)
|
||||
}
|
||||
|
||||
// Empty files write nothing and report zero.
|
||||
empty := writeFile(t, dir, "empty.jsonl", "")
|
||||
out, buf = testOutput()
|
||||
if report, err := console.ImportWinterFiles(ctx, e.gdb, reg, console.WinterOptions{MenusFile: empty, EntriesFile: empty}, out); err != nil || report.Menus != 0 || report.Entries != 0 || !strings.Contains(buf.String(), "0 menus, 0 entries") {
|
||||
t.Fatalf("empty files: %+v %v\n%s", report, err, buf.String())
|
||||
}
|
||||
if m, n := e.counts(t); m != 2 || n != 5 {
|
||||
t.Fatalf("empty files changed the tables: %d menus, %d entries", m, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
package menu
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-menu-plugin/console"
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
"git.golem15.com/golem15/sm-menu-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 (
|
||||
_ party.Plugin = (*Plugin)(nil)
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasCommands = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
// Plugin is the golem15.menu plugin.
|
||||
@@ -40,6 +43,11 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commands registers menu:import-winter.
|
||||
func (p *Plugin) Commands() []bonfire.Command {
|
||||
return []bonfire.Command{console.ImportWinter(p.app)}
|
||||
}
|
||||
|
||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||
|
||||
func (p *Plugin) Models() []any { return models.All() }
|
||||
|
||||
38
scripts/winter-export-entries.sql
Normal file
38
scripts/winter-export-entries.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Menu entries of a WinterCMS site with the Golem15.Menu plugin, one JSON
|
||||
-- object per line: the --entries input of menu:import-winter.
|
||||
--
|
||||
-- mysql -N -B --raw <database> < winter-export-entries.sql > menu-entries.jsonl
|
||||
--
|
||||
-- Read-only: it selects from the menu entries table and changes nothing.
|
||||
-- JSON_OBJECT needs MySQL 5.7.8+ or MariaDB 10.2.3+.
|
||||
--
|
||||
-- Check the table name on the server before running:
|
||||
-- SHOW TABLES LIKE '%menu_menu_entries';
|
||||
-- This file assumes golem15_menu_menu_entries.
|
||||
--
|
||||
-- Check the entry types first; the command maps link to url and
|
||||
-- blog_categories to all-journal-categories, drops page entries, and drops
|
||||
-- any other type unless --map old=new names its new type:
|
||||
-- SELECT type, COUNT(*) FROM golem15_menu_menu_entries GROUP BY type;
|
||||
SELECT JSON_OBJECT(
|
||||
'id', e.id,
|
||||
'menu_id', e.menu_id,
|
||||
'parent_id', e.parent_id,
|
||||
'nest_left', e.nest_left,
|
||||
'nest_right', e.nest_right,
|
||||
'nest_depth', e.nest_depth,
|
||||
'sort_order', e.sort_order,
|
||||
'type', e.type,
|
||||
'title', e.title,
|
||||
'url', e.url,
|
||||
'reference', e.reference,
|
||||
'code', e.code,
|
||||
'icon', e.icon,
|
||||
'css_class', e.css_class,
|
||||
'target', e.target,
|
||||
'is_published', e.is_published,
|
||||
'created_at', e.created_at,
|
||||
'updated_at', e.updated_at
|
||||
)
|
||||
FROM golem15_menu_menu_entries e
|
||||
ORDER BY e.id;
|
||||
25
scripts/winter-export-menus.sql
Normal file
25
scripts/winter-export-menus.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Menus of a WinterCMS site with the Golem15.Menu plugin, one JSON object
|
||||
-- per line: the --menus input of menu:import-winter.
|
||||
--
|
||||
-- mysql -N -B --raw <database> < winter-export-menus.sql > menus.jsonl
|
||||
--
|
||||
-- Read-only: it selects from the menus table and changes nothing.
|
||||
-- JSON_OBJECT needs MySQL 5.7.8+ or MariaDB 10.2.3+.
|
||||
--
|
||||
-- Check the table name on the server before running:
|
||||
-- SHOW TABLES LIKE '%menu_menus';
|
||||
-- This file assumes golem15_menu_menus.
|
||||
--
|
||||
-- Export the entries with winter-export-entries.sql in the same run: the
|
||||
-- entries refer to these ids, and the command keeps both.
|
||||
SELECT JSON_OBJECT(
|
||||
'id', m.id,
|
||||
'name', m.name,
|
||||
'code', m.code,
|
||||
'is_active', m.is_active,
|
||||
'css_class', m.css_class,
|
||||
'created_at', m.created_at,
|
||||
'updated_at', m.updated_at
|
||||
)
|
||||
FROM golem15_menu_menus m
|
||||
ORDER BY m.id;
|
||||
Reference in New Issue
Block a user