diff --git a/console/helpers.go b/console/helpers.go new file mode 100644 index 0000000..7b1b3c7 --- /dev/null +++ b/console/helpers.go @@ -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 +} diff --git a/console/import_winter.go b/console/import_winter.go new file mode 100644 index 0000000..7e4b1d0 --- /dev/null +++ b/console/import_winter.go @@ -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=", 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 +} diff --git a/menu_smoke_test.go b/menu_smoke_test.go index 5772d38..012f956 100644 --- a/menu_smoke_test.go +++ b/menu_smoke_test.go @@ -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) + } +} diff --git a/plugin.go b/plugin.go index 0faa4bf..9720b49 100644 --- a/plugin.go +++ b/plugin.go @@ -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() } diff --git a/scripts/winter-export-entries.sql b/scripts/winter-export-entries.sql new file mode 100644 index 0000000..0c9b607 --- /dev/null +++ b/scripts/winter-export-entries.sql @@ -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 < 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; diff --git a/scripts/winter-export-menus.sql b/scripts/winter-export-menus.sql new file mode 100644 index 0000000..56da1ea --- /dev/null +++ b/scripts/winter-export-menus.sql @@ -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 < 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;