package models import ( "fmt" "strconv" "strings" "time" "gorm.io/gorm" ) // The built-in entry types (D-05). Every other type is a menu item type of // the framework's registry (signpost). PHP stored the link type as "link"; // the Winter import maps it to TypeURL. const ( // TypeURL is a link to the entry's stored URL. TypeURL = "url" // TypeDivider is a separator with no URL. TypeDivider = "divider" ) // Builtin reports whether typ is a built-in entry type. func Builtin(typ string) bool { return typ == TypeURL || typ == TypeDivider } // MenuEntry is one entry of a menu, stored on golem15_menu_menu_entries: the // PHP Golem15\Menu\Models\MenuEntry. Entries form a tree per menu through // parent_id and are ordered by sort_order within their parent. The nest // columns are kept for the PHP schema; nothing reads them. type MenuEntry struct { ID uint `gorm:"column:id;primaryKey"` MenuID uint `gorm:"column:menu_id"` Menu *Menu `gorm:"foreignKey:MenuID" json:"-"` ParentID *uint `gorm:"column:parent_id"` Parent *MenuEntry `gorm:"foreignKey:ParentID" json:"-"` NestLeft *int `gorm:"column:nest_left"` NestRight *int `gorm:"column:nest_right"` NestDepth *int `gorm:"column:nest_depth"` SortOrder *int `gorm:"column:sort_order"` Type string `gorm:"column:type"` Title *string `gorm:"column:title"` URL *string `gorm:"column:url"` URLPattern *string `gorm:"column:url_pattern"` Reference *string `gorm:"column:reference"` Code *string `gorm:"column:code"` Icon *string `gorm:"column:icon"` CSSClass *string `gorm:"column:css_class"` Target *string `gorm:"column:target"` IsPublished *bool `gorm:"column:is_published"` CreatedAt *time.Time `gorm:"column:created_at"` UpdatedAt *time.Time `gorm:"column:updated_at"` // requestedMenuID is the menu a save asked for when BeforeSave replaced // it with the parent's menu (see RequestedMenuID). requestedMenuID uint } func (MenuEntry) TableName() string { return "golem15_menu_menu_entries" } // MorphName is the PHP class string of the model. func (MenuEntry) MorphName() string { return `Golem15\Menu\Models\MenuEntry` } // Fillable is the list an admin save may set. The nest columns and // sort_order are not: the save hook and the reorder page set them. func (MenuEntry) Fillable() []string { return []string{"menu_id", "parent_id", "type", "title", "url", "url_pattern", "reference", "code", "icon", "css_class", "target", "is_published"} } // Rules require a type; the admin controller checks the type, URL, target, // menu and parent beyond that. func (MenuEntry) Rules() map[string]string { return map[string]string{"type": "required"} } // BeforeSave ports the PHP beforeSave: the nest columns become 0 when unset, // an entry with a parent takes the parent's menu, and an entry without a // sort_order goes after the menu's last one. A missing title is stored empty // (dividers have none) and a missing published flag is the PHP default true. // When the entry named another menu than its parent's, RequestedMenuID // reports the menu it named; the admin refuses such a save. func (e *MenuEntry) BeforeSave(tx *gorm.DB) error { e.requestedMenuID = 0 zero := func(p **int) { if *p == nil { v := 0 *p = &v } } zero(&e.NestLeft) zero(&e.NestRight) zero(&e.NestDepth) if e.Title == nil { empty := "" e.Title = &empty } if e.IsPublished == nil { on := true e.IsPublished = &on } db := tx.Session(&gorm.Session{NewDB: true}) if e.ParentID != nil && *e.ParentID != 0 { var menuIDs []uint if err := db.Model(&MenuEntry{}).Where("id = ?", *e.ParentID).Limit(1).Pluck("menu_id", &menuIDs).Error; err != nil { return err } if len(menuIDs) > 0 && menuIDs[0] != 0 { if e.MenuID != 0 && e.MenuID != menuIDs[0] { e.requestedMenuID = e.MenuID } e.MenuID = menuIDs[0] } } if e.SortOrder == nil { var max *int if err := db.Model(&MenuEntry{}).Where("menu_id = ?", e.MenuID).Select("MAX(sort_order)").Scan(&max).Error; err != nil { return err } next := 1 if max != nil { next = *max + 1 } e.SortOrder = &next } return nil } // RequestedMenuID is the menu the last save of e named when BeforeSave // replaced it with the parent's menu: the parent belongs to another menu. // It is 0 when the entry named no menu or its parent's. func (e *MenuEntry) RequestedMenuID() uint { return e.requestedMenuID } // Text is the value of an optional text column, "" when null. func Text(s *string) string { if s == nil { return "" } return *s } func init() { Register(MenuEntry{}) } // FilterMenu is the config_filter.yaml scope of the menu filter. const FilterMenu = "FilterMenu" // FilterScopes lists the scopes the list filters may name. func (MenuEntry) FilterScopes() []string { return []string{FilterMenu} } // FilterScope narrows the list to the entries of the chosen menus. func (MenuEntry) FilterScope(name string, db *gorm.DB, value any) *gorm.DB { if db == nil || name != FilterMenu { return db } ids := filterIDs(value) if len(ids) == 0 { return db } return db.Where("menu_id IN ?", ids) } // filterIDs reads one id or a list of ids from a filter value. func filterIDs(value any) []uint64 { var raw []any switch v := value.(type) { case []any: raw = v case []string: for _, s := range v { raw = append(raw, s) } default: raw = []any{v} } var out []uint64 for _, item := range raw { text := strings.TrimSpace(fmt.Sprint(item)) if item == nil || text == "" { continue } if id, err := strconv.ParseUint(text, 10, 32); err == nil && id > 0 { out = append(out, id) } } return out }