feat(17-03): register the journal-post menu item type
- Plugin implements pact.HasMenuItemTypes with a lazy app accessor - journal-post: references by title, resolve fills the layered pattern by ordered replacement (id, slug, category, year, month, day) and builds the ordered per-locale alternates with translated slugs (x-default without enabled locales) - menuitem.journal_post labels in en and pl - TestMenuItemTypesSmoke: host override, item override, sort_order alternates
This commit is contained in:
439
classes/menu_item_types.go
Normal file
439
classes/menu_item_types.go
Normal file
@@ -0,0 +1,439 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The Journal menu item type keys, the PHP keys of Plugin.php
|
||||
// extendWinterPagesPlugin (D-04).
|
||||
const (
|
||||
MenuTypePost = "journal-post"
|
||||
)
|
||||
|
||||
// The phrase keys of the menu item type labels.
|
||||
const (
|
||||
menuLabelPost = "golem15.journal::lang.menuitem.journal_post"
|
||||
)
|
||||
|
||||
// The default URL patterns of the menu item types. A host overrides them
|
||||
// through the registry's menu_items.patterns config, an item through its own
|
||||
// URL pattern.
|
||||
const (
|
||||
DefaultPostPattern = "/blog/:slug"
|
||||
)
|
||||
|
||||
// xDefaultLocale is the single alternate's locale when translate has no
|
||||
// enabled locale (PHP buildAlternateLinks).
|
||||
const xDefaultLocale = "x-default"
|
||||
|
||||
// postPlaceholders are the placeholders a post URL pattern understands, in
|
||||
// the order PHP getMenuTypeInfo lists them.
|
||||
var postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"}
|
||||
|
||||
// MenuItemTypes returns the Journal menu item types, the typed successors
|
||||
// of the PHP pages.menuitem handlers of Post and Category (headless branch):
|
||||
//
|
||||
// - journal-post: one post picked by id (References lists every post by
|
||||
// title); it resolves to the post's URL without a published filter.
|
||||
//
|
||||
// URLs are built from the pattern the registry layered (MenuItemResolve
|
||||
// Pattern) by ordered literal replacement of the placeholders, as PHP
|
||||
// buildUrlFromPattern did: posts fill :id, :slug, :category (the slug of the
|
||||
// post's first category by name) and, for a published post with a publish
|
||||
// date, :year, :month and :day (UTC). Alternates hold one URL per enabled
|
||||
// translate locale in sort_order (ties by id): the unprefixed locale
|
||||
// (MenuItemResolve.UnprefixedLocale, else translate's default locale) has no
|
||||
// prefix, every other locale the prefix /code, and slugs are translated per
|
||||
// locale with the default value as fallback. Without an enabled locale the
|
||||
// only alternate is x-default. The item URL is the first alternate's URL, as
|
||||
// PHP took reset($alternateLinks).
|
||||
//
|
||||
// app is asked for the booted application at request time; its *gorm.DB is
|
||||
// the database the types read.
|
||||
func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
|
||||
m := menuTypes{app: app}
|
||||
return []pact.MenuItemType{
|
||||
{
|
||||
Key: MenuTypePost,
|
||||
Label: menuLabelPost,
|
||||
DefaultPattern: DefaultPostPattern,
|
||||
Placeholders: append([]string(nil), postPlaceholders...),
|
||||
References: m.postReferences,
|
||||
Resolve: m.resolvePost,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// menuTypes carries the lazy app accessor the type closures share.
|
||||
type menuTypes struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
|
||||
func (m menuTypes) db(ctx context.Context) (*gorm.DB, error) {
|
||||
if m.app == nil {
|
||||
return nil, errors.New("golem15.journal: menu item types: app is not available")
|
||||
}
|
||||
app := m.app()
|
||||
if app == nil {
|
||||
return nil, errors.New("golem15.journal: menu item types: app is not booted")
|
||||
}
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return nil, errors.New("golem15.journal: menu item types: database is not configured")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return db.WithContext(ctx), nil
|
||||
}
|
||||
|
||||
// postReferences lists every post ordered by title (PHP getMenuTypeInfo),
|
||||
// ties by id.
|
||||
func (m menuTypes) postReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var posts []models.Post
|
||||
if err := db.Select("id", "title").Order("title ASC, id ASC").Find(&posts).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item post references: %w", err)
|
||||
}
|
||||
refs := make([]pact.MenuItemReference, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(p.ID), 10), Label: p.Title})
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// resolvePost resolves one journal-post item. An unknown or missing
|
||||
// reference resolves to nothing, as PHP returned null.
|
||||
func (m menuTypes) resolvePost(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
id, ok := menuReferenceID(in.Reference)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var posts []models.Post
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item post %d: %w", id, err)
|
||||
}
|
||||
if len(posts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
items, err := resolvePostItems(ctx, db, posts, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &items[0], nil
|
||||
}
|
||||
|
||||
// resolvePostItems resolves posts to one item each, in the given order.
|
||||
func resolvePostItems(ctx context.Context, db *gorm.DB, posts []models.Post, in pact.MenuItemResolve) ([]pact.ResolvedMenuItem, error) {
|
||||
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]uint, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
postTr, err := loadMenuTranslations(ctx, db, models.Post{}.MorphName(), ids, locales)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firsts, err := firstPostCategories(ctx, db, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catIDs := []uint{}
|
||||
for _, c := range firsts {
|
||||
catIDs = append(catIDs, c.ID)
|
||||
}
|
||||
catTr, err := loadMenuTranslations(ctx, db, models.Category{}.MorphName(), catIDs, locales)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]pact.ResolvedMenuItem, 0, len(posts))
|
||||
for i := range posts {
|
||||
p := &posts[i]
|
||||
cat, hasCat := firsts[p.ID]
|
||||
params := func(locale string) [][2]string {
|
||||
slug := p.Slug
|
||||
catSlug := ""
|
||||
if hasCat {
|
||||
catSlug = cat.Slug
|
||||
}
|
||||
if locale != "" {
|
||||
slug = postTr.value(locale, p.ID, "slug", slug)
|
||||
if hasCat {
|
||||
catSlug = catTr.value(locale, cat.ID, "slug", catSlug)
|
||||
}
|
||||
}
|
||||
return postURLParams(p, slug, catSlug)
|
||||
}
|
||||
title := func(locale string) string {
|
||||
if locale == "" {
|
||||
return p.Title
|
||||
}
|
||||
return postTr.value(locale, p.ID, "title", p.Title)
|
||||
}
|
||||
out = append(out, resolvedMenuItem(in.Pattern, locales, p.Title, p.UpdatedAt, params, title))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// postURLParams are a post's placeholder values in PHP
|
||||
// getHeadlessUrlParams order: id, slug, category, then year, month and day
|
||||
// for a published post with a publish date.
|
||||
func postURLParams(p *models.Post, slug, categorySlug string) [][2]string {
|
||||
params := [][2]string{
|
||||
{"id", strconv.FormatUint(uint64(p.ID), 10)},
|
||||
{"slug", slug},
|
||||
{"category", categorySlug},
|
||||
}
|
||||
if p.Published && p.PublishedAt != nil {
|
||||
at := p.PublishedAt.UTC()
|
||||
params = append(params,
|
||||
[2]string{"year", fmt.Sprintf("%04d", at.Year())},
|
||||
[2]string{"month", fmt.Sprintf("%02d", int(at.Month()))},
|
||||
[2]string{"day", fmt.Sprintf("%02d", at.Day())},
|
||||
)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// firstPostCategories maps each post id to its first category by name (the
|
||||
// PHP categories relation's order), ties by id.
|
||||
func firstPostCategories(ctx context.Context, db *gorm.DB, postIDs []uint) (map[uint]models.Category, error) {
|
||||
out := map[uint]models.Category{}
|
||||
if len(postIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []struct {
|
||||
PostID uint
|
||||
models.Category
|
||||
}
|
||||
err := db.WithContext(ctx).
|
||||
Table("golem15_journal_categories AS c").
|
||||
Select("pc.post_id AS post_id, c.*").
|
||||
Joins("JOIN golem15_journal_posts_categories AS pc ON pc.category_id = c.id").
|
||||
Where("pc.post_id IN ?", postIDs).
|
||||
Order("c.name ASC, c.id ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item post categories: %w", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if _, seen := out[r.PostID]; !seen {
|
||||
out[r.PostID] = r.Category
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolvedMenuItem builds one resolved item: its alternates from pattern
|
||||
// per locale, its URL the first alternate's URL (else the pattern filled
|
||||
// with the default values), its title and mtime.
|
||||
func resolvedMenuItem(pattern string, locales menuLocales, title string, mtime *time.Time, params func(locale string) [][2]string, titleIn func(locale string) string) pact.ResolvedMenuItem {
|
||||
alternates := menuAlternates(pattern, locales, params, titleIn)
|
||||
url := ""
|
||||
if len(alternates) > 0 {
|
||||
url = alternates[0].URL
|
||||
}
|
||||
if url == "" {
|
||||
url = fillMenuPattern(pattern, params(""))
|
||||
}
|
||||
item := pact.ResolvedMenuItem{
|
||||
Title: title,
|
||||
URL: url,
|
||||
Alternates: alternates,
|
||||
}
|
||||
if mtime != nil {
|
||||
t := *mtime
|
||||
item.MTime = &t
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// menuAlternates is PHP buildAlternateLinks: x-default alone without an
|
||||
// enabled locale, else one URL per enabled locale in order, prefixed with
|
||||
// /code except for the unprefixed locale.
|
||||
func menuAlternates(pattern string, locales menuLocales, params func(locale string) [][2]string, title func(locale string) string) []pact.MenuItemAlternate {
|
||||
if len(locales.codes) == 0 {
|
||||
return []pact.MenuItemAlternate{{
|
||||
Locale: xDefaultLocale,
|
||||
URL: fillMenuPattern(pattern, params("")),
|
||||
Title: title(""),
|
||||
}}
|
||||
}
|
||||
out := make([]pact.MenuItemAlternate, 0, len(locales.codes))
|
||||
for _, code := range locales.codes {
|
||||
prefix := ""
|
||||
if code != locales.unprefixed {
|
||||
prefix = "/" + code
|
||||
}
|
||||
out = append(out, pact.MenuItemAlternate{
|
||||
Locale: code,
|
||||
URL: prefix + fillMenuPattern(pattern, params(code)),
|
||||
Title: title(code),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillMenuPattern replaces each ":key" in pattern with its value, in params
|
||||
// order, as PHP buildUrlFromPattern's str_replace loop did.
|
||||
func fillMenuPattern(pattern string, params [][2]string) string {
|
||||
url := pattern
|
||||
for _, kv := range params {
|
||||
url = strings.ReplaceAll(url, ":"+kv[0], kv[1])
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// menuReferenceID parses an item reference as a record id.
|
||||
func menuReferenceID(ref string) (uint, bool) {
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(ref), 10, 32)
|
||||
if err != nil || n == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint(n), true
|
||||
}
|
||||
|
||||
// menuLocales is the enabled translate locales in sort_order (ties by id),
|
||||
// the default locale whose values live on the host columns, and the locale
|
||||
// whose URLs carry no prefix.
|
||||
type menuLocales struct {
|
||||
codes []string
|
||||
defaultCode string
|
||||
unprefixed string
|
||||
}
|
||||
|
||||
// loadMenuLocales reads the enabled locales. unprefixed names the
|
||||
// unprefixed locale; "" means translate's default locale.
|
||||
func loadMenuLocales(ctx context.Context, db *gorm.DB, unprefixed string) (menuLocales, error) {
|
||||
var rows []translatemodels.Locale
|
||||
err := db.WithContext(ctx).
|
||||
Where("is_enabled = ?", true).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return menuLocales{}, fmt.Errorf("golem15.journal: menu item locales: %w", err)
|
||||
}
|
||||
out := menuLocales{unprefixed: strings.TrimSpace(unprefixed)}
|
||||
for _, l := range rows {
|
||||
out.codes = append(out.codes, l.Code)
|
||||
if l.IsDefault && out.defaultCode == "" {
|
||||
out.defaultCode = l.Code
|
||||
}
|
||||
}
|
||||
if out.unprefixed == "" {
|
||||
out.unprefixed = out.defaultCode
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// menuTranslations holds the translated attributes of a set of records:
|
||||
// locale, then record id, then field.
|
||||
type menuTranslations struct {
|
||||
defaultCode string
|
||||
data map[string]map[uint]map[string]any
|
||||
}
|
||||
|
||||
// value is the record's field in locale with PHP's fallback: the default
|
||||
// locale and a missing or falsy translation read the host value.
|
||||
func (t menuTranslations) value(locale string, id uint, field, host string) string {
|
||||
if locale == "" || locale == t.defaultCode {
|
||||
return host
|
||||
}
|
||||
v, ok := t.data[locale][id][field]
|
||||
if !ok || !phpTruthy(v) {
|
||||
return host
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
// loadMenuTranslations reads the translate attribute rows of the records
|
||||
// ids of morph in every non-default enabled locale with one query.
|
||||
func loadMenuTranslations(ctx context.Context, db *gorm.DB, morph string, ids []uint, locales menuLocales) (menuTranslations, error) {
|
||||
out := menuTranslations{defaultCode: locales.defaultCode, data: map[string]map[uint]map[string]any{}}
|
||||
var codes []string
|
||||
for _, c := range locales.codes {
|
||||
if c != locales.defaultCode {
|
||||
codes = append(codes, c)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 || len(codes) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
keys := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
keys = append(keys, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
var rows []translatemodels.Attribute
|
||||
err := db.WithContext(ctx).
|
||||
Where("model_type = ? AND locale IN ? AND model_id IN ?", morph, codes, keys).
|
||||
Order("id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("golem15.journal: menu item translations: %w", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.ModelID == nil || r.AttributeData == nil || strings.TrimSpace(*r.AttributeData) == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(*r.ModelID), 10, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(*r.AttributeData), &fields); err != nil {
|
||||
continue
|
||||
}
|
||||
if out.data[r.Locale] == nil {
|
||||
out.data[r.Locale] = map[uint]map[string]any{}
|
||||
}
|
||||
if _, seen := out.data[r.Locale][uint(id)]; !seen {
|
||||
out.data[r.Locale][uint(id)] = fields
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// phpTruthy is PHP's boolean cast of a decoded JSON value, the test PHP
|
||||
// hasTranslation applied before it used a translation.
|
||||
func phpTruthy(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case string:
|
||||
return t != "" && t != "0"
|
||||
case bool:
|
||||
return t
|
||||
case float64:
|
||||
return t != 0
|
||||
case []any:
|
||||
return len(t) > 0
|
||||
case map[string]any:
|
||||
return len(t) > 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user