diff --git a/classes/menu_item_types.go b/classes/menu_item_types.go new file mode 100644 index 0000000..0750482 --- /dev/null +++ b/classes/menu_item_types.go @@ -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 +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 8015594..87c98c5 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -131,6 +131,8 @@ tag: delete_confirm: Delete this tag? delete_success: Successfully deleted those tags. return_to_tags: Return to the journal tag list +menuitem: + journal_post: Journal post scopes: journal: Journal journal_description: Create, edit, publish and delete posts as you, within your Journal permissions. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 4d894a4..04f93af 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -83,6 +83,8 @@ category: delete_success: Kategorie zostały pomyślnie usunięte. return_to_categories: Wróć do listy kategorii reorder: Zmień kolejność kategorii +menuitem: + journal_post: Wpis (Journal) scopes: journal: Journal journal_description: Twórz, edytuj, publikuj i usuwaj posty jako Ty, w ramach swoich uprawnień Journal. diff --git a/menu_item_types.go b/menu_item_types.go new file mode 100644 index 0000000..d9c058d --- /dev/null +++ b/menu_item_types.go @@ -0,0 +1,22 @@ +package journal + +import ( + "git.golem15.com/golem15/sm-journal-plugin/classes" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// MenuItemTypes registers the Journal menu item types under their PHP keys +// (classes.MenuItemTypes) with a lazy accessor of the booted app, so menus +// and sitemaps can address Journal posts and categories through the +// framework's menu item type registry. +func (p *Plugin) MenuItemTypes() []pact.MenuItemType { + return classes.MenuItemTypes(func() *backpack.App { + if p == nil { + return nil + } + return p.app + }) +} + +var _ pact.HasMenuItemTypes = (*Plugin)(nil) diff --git a/menu_item_types_smoke_test.go b/menu_item_types_smoke_test.go new file mode 100644 index 0000000..bd7c18d --- /dev/null +++ b/menu_item_types_smoke_test.go @@ -0,0 +1,150 @@ +package journal_test + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/sm-journal-plugin/classes" + "git.golem15.com/golem15/sm-journal-plugin/models" + translate "git.golem15.com/golem15/sm-translate-plugin/classes" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/signpost" + "gocloud.dev/blob/memblob" + "gorm.io/gorm" +) + +// bootMenuItemTypes boots translate and Journal on a fresh database with +// extra config files (name to YAML) and returns the app, the database and +// the signpost registry. +func bootMenuItemTypes(t *testing.T, files map[string]string) (*backpack.App, *gorm.DB, *signpost.Registry) { + t.Helper() + sqlDB := dedicatedDB(t, "journal_menu_"+strconv.FormatInt(time.Now().UnixNano(), 10)) + gdb, err := lagoon.Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + files["app.yaml"] = "name: journal-menu\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n" + for name, body := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + cfg, err := compass.Open(compass.Options{ + Dir: dir, + Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}, + }) + if err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + if err := lagoon.Publish(app, sqlDB, gdb); err != nil { + t.Fatal(err) + } + bucket := memblob.OpenBucket(nil) + t.Cleanup(func() { _ = bucket.Close() }) + if err := attach.Publish(app, bucket); err != nil { + t.Fatal(err) + } + plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"}) + if err != nil { + t.Fatal(err) + } + if err := lagoon.Migrate(gdb, plugins); err != nil { + t.Fatal(err) + } + reg, ok := app.Lookup[*signpost.Registry]() + if !ok || reg == nil { + t.Fatal("signpost registry is not published") + } + return app, gdb, reg +} + +func menuPost(t *testing.T, gdb *gorm.DB, title, slug string, published bool, at *time.Time) models.Post { + t.Helper() + p := models.Post{Title: title, Slug: slug, Content: "x", Published: published, PublishedAt: at} + if err := gdb.Create(&p).Error; err != nil { + t.Fatal(err) + } + return p +} + +func altString(alts []pact.MenuItemAlternate) string { + parts := make([]string, 0, len(alts)) + for _, a := range alts { + parts = append(parts, a.Locale+"="+a.URL) + } + return strings.Join(parts, " ") +} + +// TestMenuItemTypesSmoke boots Journal with translate, checks the +// registered types and resolves them through the signpost registry with host +// pattern overrides and ordered alternates. +func TestMenuItemTypesSmoke(t *testing.T) { + requirePostgres(t) + ctx := context.Background() + _, gdb, reg := bootMenuItemTypes(t, map[string]string{ + "menu_items.yaml": "patterns:\n journal-post: /news/:slug\n", + }) + + typ, ok := reg.Type(classes.MenuTypePost) + if !ok { + t.Fatalf("%s is not registered", classes.MenuTypePost) + } + if typ.DefaultPattern != classes.DefaultPostPattern || typ.References == nil { + t.Fatalf("journal-post type = %+v", typ) + } + + // pl sorts before the default en, so the first alternate (the item + // URL, PHP reset()) is the prefixed pl URL. + if err := gdb.Exec(`UPDATE golem15_translate_locales SET sort_order = CASE code WHEN 'pl' THEN 1 WHEN 'en' THEN 2 ELSE sort_order END`).Error; err != nil { + t.Fatal(err) + } + at := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC) + post := menuPost(t, gdb, "Hello", "hello", true, &at) + if err := translate.SetTranslated(ctx, gdb, &post, "slug", "pl", "witaj"); err != nil { + t.Fatal(err) + } + menuPost(t, gdb, "Another", "another", false, nil) + + refs, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypePost) + if err != nil || !refs.Applies || len(refs.Options) != 2 || refs.Options[0].Label != "Another" || refs.Options[1].Label != "Hello" { + t.Fatalf("post references = %+v err=%v", refs, err) + } + + got, err := reg.Resolve(ctx, classes.MenuTypePost, strconv.FormatUint(uint64(post.ID), 10), "", false, "") + if err != nil || got == nil { + t.Fatalf("resolve journal-post: %+v %v", got, err) + } + if want := "pl=/pl/news/witaj en=/news/hello"; altString(got.Alternates) != want { + t.Fatalf("alternates = %q, want %q", altString(got.Alternates), want) + } + if got.URL != "/pl/news/witaj" || got.Title != "Hello" || got.MTime == nil { + t.Fatalf("resolved = %+v", got) + } + + // The item override wins over the host override; the unprefixed locale + // comes from the caller. + got, err = reg.Resolve(ctx, classes.MenuTypePost, strconv.FormatUint(uint64(post.ID), 10), "/:year/:month/:day/:slug", false, "pl") + if err != nil || got == nil { + t.Fatalf("resolve with item pattern: %+v %v", got, err) + } + if want := "pl=/2026/03/04/witaj en=/en/2026/03/04/hello"; altString(got.Alternates) != want { + t.Fatalf("alternates = %q, want %q", altString(got.Alternates), want) + } + + // An unknown reference resolves to nothing. + if got, err := reg.Resolve(ctx, classes.MenuTypePost, "999999", "", false, ""); err != nil || got != nil { + t.Fatalf("unknown post = %+v %v", got, err) + } +}