package classes import ( "context" "log/slog" "git.golem15.com/golem15/sm-menu-plugin/models" "git.golem15.com/golem15/summercms/modules/pact" ) // Item is one entry of the public menu tree (GET /_menu/api/v1/menus/{code}). // ID is the entry id, nil for a child a registry type generated. Type is the // stored entry type; a generated child carries its parent's registry type. // URL is nil for dividers and for links that fail AllowedURL. Target is // _self, _blank or nil. CSSClass, Icon and Code are nil when empty. Items is // never nil, so it encodes as []. type Item struct { ID *uint `json:"id"` Title string `json:"title"` Type string `json:"type"` URL *string `json:"url"` Target *string `json:"target"` CSSClass *string `json:"css_class"` Icon *string `json:"icon"` Code *string `json:"code"` Items []Item `json:"items"` } // Resolver is the part of the framework's menu item type registry the tree // reads; *signpost.Registry implements it. type Resolver interface { Type(key string) (pact.MenuItemType, bool) Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error) } // TreeOptions are the request inputs of BuildTree. type TreeOptions struct { // Registry resolves registry entries; nil leaves every registry entry // out. Registry Resolver // Locale picks the alternate of a resolved item whose locale matches; // "" or a locale without an alternate keeps the resolved URL and title. Locale string // Logger records entries left out; nil uses slog.Default. Logger *slog.Logger } // BuildTree nests a menu's entries under their parents. entries must be the // published entries of one menu ordered by sort_order then id; that order // is kept within each parent. An entry whose parent is not among entries // (unpublished or missing) is unreachable and left out with its subtree. // // Built-in types: a url entry serves its stored URL when AllowedURL accepts // it (else nil), a divider a nil URL. A registry entry is resolved through // opts.Registry with its reference, its url_pattern as the item override and // its type's Nesting. When the resolved item has a URL (a single type), the // entry serves the URL of the alternate for opts.Locale, else the resolved // URL, and its stored title when not empty, else the resolved title for the // locale. When the resolved item has no URL (a list type), the entry keeps // its stored URL (scheme-checked) and title. The resolved sub-items follow // the stored children in Items as generated children. An entry whose type is // not registered or whose reference no longer resolves is left out with its // subtree and logged. func BuildTree(ctx context.Context, entries []models.MenuEntry, opts TreeOptions) []Item { b := treeBuilder{ctx: ctx, opts: opts, children: map[uint][]*models.MenuEntry{}, seen: map[uint]bool{}} if b.ctx == nil { b.ctx = context.Background() } if b.opts.Logger == nil { b.opts.Logger = slog.Default() } var roots []*models.MenuEntry for i := range entries { e := &entries[i] if e.ParentID == nil || *e.ParentID == 0 { roots = append(roots, e) continue } b.children[*e.ParentID] = append(b.children[*e.ParentID], e) } return b.items(roots) } type treeBuilder struct { ctx context.Context opts TreeOptions children map[uint][]*models.MenuEntry seen map[uint]bool } func (b *treeBuilder) items(entries []*models.MenuEntry) []Item { out := []Item{} for _, e := range entries { if b.seen[e.ID] { continue } b.seen[e.ID] = true if item, ok := b.item(e); ok { out = append(out, item) } } return out } func (b *treeBuilder) item(e *models.MenuEntry) (Item, bool) { id := e.ID item := Item{ ID: &id, Title: models.Text(e.Title), Type: e.Type, Target: target(e.Target), CSSClass: optional(e.CSSClass), Icon: optional(e.Icon), Code: optional(e.Code), } var generated []Item switch e.Type { case models.TypeURL: item.URL = allowed(models.Text(e.URL)) case models.TypeDivider: default: resolved, ok := b.resolve(e) if !ok { return Item{}, false } if resolved.URL == "" { item.URL = allowed(models.Text(e.URL)) } else { url, title := pick(resolved, b.opts.Locale) item.URL = allowed(url) if item.Title == "" { item.Title = title } } generated = b.generated(e.Type, resolved.Items) } item.Items = append(b.items(b.children[e.ID]), generated...) return item, true } // resolve resolves a registry entry; false leaves it out. func (b *treeBuilder) resolve(e *models.MenuEntry) (*pact.ResolvedMenuItem, bool) { reg := b.opts.Registry if reg == nil { b.opts.Logger.Warn("menu: entry left out: no menu item type registry", "entry", e.ID, "type", e.Type) return nil, false } t, ok := reg.Type(e.Type) if !ok { b.opts.Logger.Warn("menu: entry left out: menu item type is not registered", "entry", e.ID, "type", e.Type) return nil, false } resolved, err := reg.Resolve(b.ctx, e.Type, models.Text(e.Reference), models.Text(e.URLPattern), t.Nesting, "") if err != nil { b.opts.Logger.Warn("menu: entry left out: resolve failed", "entry", e.ID, "type", e.Type, "reference", models.Text(e.Reference), "error", err) return nil, false } if resolved == nil { b.opts.Logger.Warn("menu: entry left out: reference does not resolve", "entry", e.ID, "type", e.Type, "reference", models.Text(e.Reference)) return nil, false } return resolved, true } // generated turns resolved sub-items into children without an id, carrying // the parent's registry type. func (b *treeBuilder) generated(typ string, resolved []pact.ResolvedMenuItem) []Item { out := make([]Item, 0, len(resolved)) for i := range resolved { url, title := pick(&resolved[i], b.opts.Locale) out = append(out, Item{ Title: title, Type: typ, URL: allowed(url), Items: b.generated(typ, resolved[i].Items), }) } return out } // pick returns the URL and title of the alternate for locale, falling back // to the resolved URL and title for a missing locale or empty values. func pick(r *pact.ResolvedMenuItem, locale string) (url, title string) { url, title = r.URL, r.Title if locale == "" { return url, title } for _, alt := range r.Alternates { if alt.Locale != locale { continue } if alt.URL != "" { url = alt.URL } if alt.Title != "" { title = alt.Title } break } return url, title } // allowed is url when it is not empty and AllowedURL accepts it, else nil. func allowed(url string) *string { if url == "" || !AllowedURL(url) { return nil } return &url } // target is the stored target when it is _self or _blank, else nil. func target(s *string) *string { v := models.Text(s) if v == "" || !AllowedTarget(v) { return nil } return &v } // optional is the stored value, nil when null or empty. func optional(s *string) *string { if s == nil || *s == "" { return nil } v := *s return &v }