diff --git a/classes/menu_item_types.go b/classes/menu_item_types.go index 9300670..e987e33 100644 --- a/classes/menu_item_types.go +++ b/classes/menu_item_types.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "sort" "strconv" "strings" "time" @@ -12,6 +13,7 @@ import ( "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/cabana" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) @@ -39,6 +41,7 @@ const ( menuLabelAllPosts = "golem15.journal::lang.menuitem.all_journal_posts" menuLabelCategoryPosts = "golem15.journal::lang.menuitem.category_journal_posts" menuLabelTag = "golem15.journal::lang.menuitem.journal_tag" + menuLabelAuthor = "golem15.journal::lang.menuitem.journal_author" ) // The default URL patterns of the menu item types. A host overrides them @@ -51,6 +54,9 @@ const ( DefaultCategoryPattern = "/blog/category/:slug" // DefaultTagPattern is the tag route of the vue-journal-layer. DefaultTagPattern = "/blog/tag/:slug" + // DefaultAuthorPattern is the author route of the vue-journal-layer; + // :author is the author slug, else the login (P33-07). + DefaultAuthorPattern = "/blog/author/:author" ) // xDefaultLocale is the single alternate's locale when translate has no @@ -63,6 +69,7 @@ var ( postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"} categoryPlaceholders = []string{":slug", ":id"} tagPlaceholders = []string{":slug", ":id"} + authorPlaceholders = []string{":author", ":id"} ) // MenuItemTypes returns the Journal menu item types, the typed successors @@ -87,8 +94,13 @@ var ( // name); it resolves to the tag's URL, /blog/tag/:slug by default, or to // nothing when the host has no tag pages (TagPagesKey false). It has no // PHP counterpart. +// - journal-author: one author's page, picked from the backend users with +// at least one published post (References lists them by name); it +// resolves to /blog/author/:author by default, :author being the author +// slug, else the login, or to nothing when the host has no author pages +// (AuthorPagesKey false). It has no PHP counterpart (Phase 33, D-43). // -// journal-category, journal-post and journal-tag carry content descriptors +// journal-category, journal-post, journal-tag and journal-author carry content descriptors // (pact.ContentDescriptor, content_descriptors.go), so consumers such as // sitemaps and metadata editors describe, page and match their records. // @@ -199,6 +211,23 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType { Kind: pact.ContentKindCollection, }, }, + { + Key: MenuTypeAuthor, + Label: menuLabelAuthor, + DefaultPattern: DefaultAuthorPattern, + Placeholders: append([]string(nil), authorPlaceholders...), + References: m.authorReferences, + Resolve: m.resolveAuthor, + Content: &pact.ContentDescriptor{ + // Authors are backend users; they have no Journal admin form. + Controller: "", + Model: cabana.BackendUserMorphType, + Records: m.listAuthors, + Describe: m.describeAuthor, + Match: m.matchAuthor, + Kind: pact.ContentKindProfile, + }, + }, } } @@ -818,3 +847,256 @@ func phpTruthy(v any) bool { } return true } + +// publishedAuthors restricts a backend_users query to live users with at +// least one published post. +func publishedAuthors(db *gorm.DB) *gorm.DB { + return db.Table("backend_users").Select(authorColumns). + Where("deleted_at IS NULL"). + Where("id IN (?)", publishedPosts(db.Session(&gorm.Session{NewDB: true}).Model(&models.Post{})).Select("user_id")) +} + +// authorLastMods maps each author id to the newest update of their +// published posts. +func authorLastMods(ctx context.Context, db *gorm.DB, ids []uint) (map[uint]time.Time, error) { + out := map[uint]time.Time{} + if len(ids) == 0 { + return out, nil + } + var rows []struct { + UserID uint `gorm:"column:user_id"` + LastMod *time.Time `gorm:"column:last_mod"` + } + if err := publishedPosts(db.WithContext(ctx).Model(&models.Post{})). + Select("user_id, MAX(updated_at) AS last_mod").Where("user_id IN ?", ids). + Group("user_id").Scan(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + if r.LastMod != nil { + out[r.UserID] = *r.LastMod + } + } + return out, nil +} + +// authorParams are an author's placeholder values (not translatable). +func authorParams(a authorRow) func(string) [][2]string { + return func(string) [][2]string { + return [][2]string{{"author", a.slug()}, {"id", strconv.FormatUint(uint64(a.ID), 10)}} + } +} + +// authorReferences lists the authors with a published post by name, ties +// by id. +func (m menuTypes) authorReferences(ctx context.Context) ([]pact.MenuItemReference, error) { + db, err := m.db(ctx) + if err != nil { + return nil, err + } + var rows []authorRow + if err := publishedAuthors(db).Find(&rows).Error; err != nil { + return nil, fmt.Errorf("golem15.journal: menu item author references: %w", err) + } + refs := make([]pact.MenuItemReference, 0, len(rows)) + for _, a := range rows { + refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(a.ID), 10), Label: a.name()}) + } + sortReferences(refs) + return refs, nil +} + +// sortReferences orders references by label, ties by id. +func sortReferences(refs []pact.MenuItemReference) { + sort.SliceStable(refs, func(i, j int) bool { + if refs[i].Label != refs[j].Label { + return refs[i].Label < refs[j].Label + } + a, _ := strconv.ParseUint(refs[i].Value, 10, 64) + b, _ := strconv.ParseUint(refs[j].Value, 10, 64) + return a < b + }) +} + +// findPublishedAuthor reads one author with a published post by id. +func findPublishedAuthor(db *gorm.DB, id uint) (*authorRow, error) { + var rows []authorRow + if err := publishedAuthors(db).Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +// resolveAuthor resolves one journal-author item; an unknown author, one +// without a published post, and any author on a host without author pages +// resolve to nothing. +func (m menuTypes) resolveAuthor(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { + id, ok := menuReferenceID(in.Reference) + if !ok || !m.authorPages() { + return nil, nil + } + db, err := m.db(ctx) + if err != nil { + return nil, err + } + a, err := findPublishedAuthor(db, id) + if err != nil || a == nil { + return nil, err + } + locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale) + if err != nil { + return nil, err + } + mods, err := authorLastMods(ctx, db, []uint{a.ID}) + if err != nil { + return nil, err + } + var mtime *time.Time + if t, ok := mods[a.ID]; ok { + mtime = &t + } + name := a.name() + item := resolvedMenuItem(in.Pattern, locales, name, mtime, authorParams(*a), func(string) string { return name }) + return &item, nil +} + +// listAuthors is the Records of journal-author: the authors with a +// published post, in id order. +func (m menuTypes) listAuthors(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) { + db, err := m.contentDB(ctx) + if err != nil { + return pact.ContentPage{}, err + } + limit := pageLimit(in.Limit) + var rows []authorRow + if err := publishedAuthors(db).Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&rows).Error; err != nil { + return pact.ContentPage{}, err + } + more := len(rows) > limit + if more { + rows = rows[:limit] + } + records, err := m.describeAuthors(ctx, db, rows, true, in.Pattern, in.UnprefixedLocale) + if err != nil { + return pact.ContentPage{}, err + } + return contentPage(records, more), nil +} + +// describeAuthor describes one backend user as an author page; Published +// tells whether they have a published post (the only authors with a page). +func (m menuTypes) describeAuthor(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) { + id, ok := menuReferenceID(in.ID) + if !ok { + return nil, nil + } + db, err := m.contentDB(ctx) + if err != nil { + return nil, err + } + var rows []authorRow + if err := db.Table("backend_users").Select(authorColumns).Where("deleted_at IS NULL AND id = ?", id).Limit(1).Find(&rows).Error; err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + published, err := findPublishedAuthor(db, id) + if err != nil { + return nil, err + } + records, err := m.describeAuthors(ctx, db, rows, published != nil, in.Pattern, in.UnprefixedLocale) + if err != nil { + return nil, err + } + return &records[0], nil +} + +// describeAuthors describes authors: the name as every locale's Title, the +// author URL per locale (empty without author pages) and the newest update +// of their published posts as LastMod. +func (m menuTypes) describeAuthors(ctx context.Context, db *gorm.DB, rows []authorRow, published bool, pattern, unprefixed string) ([]pact.ContentRecord, error) { + locales, err := loadMenuLocales(ctx, db, unprefixed) + if err != nil { + return nil, err + } + ids := make([]uint, 0, len(rows)) + for _, a := range rows { + ids = append(ids, a.ID) + } + mods, err := authorLastMods(ctx, db, ids) + if err != nil { + return nil, err + } + pages := m.authorPages() + out := make([]pact.ContentRecord, 0, len(rows)) + for _, a := range rows { + rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(a.ID), 10), Published: published} + if t, ok := mods[a.ID]; ok { + rec.LastMod = &t + } + name := a.name() + for _, alt := range menuAlternates(pattern, locales, authorParams(a), func(string) string { return name }) { + url := alt.URL + if !pages { + url = "" + } + rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: alt.Locale, URL: url, Title: name}) + } + out = append(out, rec) + } + return out, nil +} + +// matchAuthor is the Match of journal-author: by :author (the author slug, +// then the login) or :id among the authors with a published post, exact +// only on the author's own URL; nothing matches on a host without author +// pages. +func (m menuTypes) matchAuthor(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) { + if !m.authorPages() { + return nil, nil + } + db, err := m.contentDB(ctx) + if err != nil { + return nil, err + } + find := func(values map[string]string, _ string, _ menuLocales) (uint, error) { + var rows []authorRow + switch { + case values["author"] != "": + for _, cond := range []string{"golem15_bloghub_author_slug = ?", "login = ?"} { + if err := publishedAuthors(db).Where(cond, values["author"]).Order("id ASC").Limit(1).Find(&rows).Error; err != nil { + return 0, err + } + if len(rows) > 0 { + return rows[0].ID, nil + } + } + return 0, nil + case values["id"] != "": + id, ok := menuReferenceID(values["id"]) + if !ok { + return 0, nil + } + a, err := findPublishedAuthor(db, id) + if err != nil || a == nil { + return 0, err + } + return a.ID, nil + } + return 0, nil + } + urls := func(id uint) ([]pact.MenuItemAlternate, error) { + a, err := findPublishedAuthor(db, id) + if err != nil || a == nil { + return nil, err + } + locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale) + if err != nil { + return nil, err + } + name := a.name() + return menuAlternates(in.Pattern, locales, authorParams(*a), func(string) string { return name }), nil + } + return matchContent(ctx, db, in, find, urls) +} diff --git a/classes/menu_item_types_test.go b/classes/menu_item_types_test.go index 3a99212..0fae19e 100644 --- a/classes/menu_item_types_test.go +++ b/classes/menu_item_types_test.go @@ -12,14 +12,14 @@ import ( "git.golem15.com/golem15/summercms/modules/pact" ) -// TestMenuItemTypesPatterns pins the Docker-free half of D-04: the six -// registered types (the five PHP ones and journal-tag), the ordered placeholder fill of PHP buildUrlFromPattern, +// TestMenuItemTypesPatterns pins the Docker-free half of D-04: the seven +// registered types (the five PHP ones, journal-tag and journal-author), the ordered placeholder fill of PHP buildUrlFromPattern, // the post placeholders (date parts only for a published post), the // alternates (x-default, unprefixed locale, prefixes, first alternate as the // URL), the translation fallback, the reference parse, the category tree and // the refusals without a booted app. func TestMenuItemTypesPatterns(t *testing.T) { - t.Run("the six types", func(t *testing.T) { + t.Run("the seven types", func(t *testing.T) { types := MenuItemTypes(nil) var got []string for _, typ := range types { @@ -36,6 +36,7 @@ func TestMenuItemTypesPatterns(t *testing.T) { "all-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=false|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.all_journal_posts", "category-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.category_journal_posts", "journal-tag|/blog/tag/:slug||nest=false|dyn=false|refs=true|:slug,:id|golem15.journal::lang.menuitem.journal_tag", + "journal-author|/blog/author/:author||nest=false|dyn=false|refs=true|:author,:id|golem15.journal::lang.menuitem.journal_author", } if strings.Join(got, "\n") != strings.Join(want, "\n") { t.Fatalf("types:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) diff --git a/content_descriptors_test.go b/content_descriptors_test.go index 8dc0332..7bab0e9 100644 --- a/content_descriptors_test.go +++ b/content_descriptors_test.go @@ -102,7 +102,7 @@ func TestContentDescriptors(t *testing.T) { _, gdb, reg := bootMenuItemTypes(t, map[string]string{}) s := seedContent(t, gdb) - if got := contentKeys(reg.ContentTypes()); got != "journal-category journal-post journal-tag" { + if got := contentKeys(reg.ContentTypes()); got != "journal-category journal-post journal-tag journal-author" { t.Fatalf("ContentTypes = %s", got) } for controller, key := range map[string]string{ diff --git a/journal_descriptor_fields_test.go b/journal_descriptor_fields_test.go index 325bad5..b470f92 100644 --- a/journal_descriptor_fields_test.go +++ b/journal_descriptor_fields_test.go @@ -185,3 +185,81 @@ func TestJournalDescriptorFields(t *testing.T) { }) } } + +// TestJournalAuthorType (D-43, D-34, P33-07): journal-author lists the +// authors with a published post, describes them as profiles at +// /blog/author/:author (the author slug, else the login) per locale, matches +// exactly on the pattern, and with golem15.journal.author_pages false +// describes empty URLs and never matches. +func TestJournalAuthorType(t *testing.T) { + requirePostgres(t) + for _, tc := range []struct { + name string + files map[string]string + on bool + }{ + {"default", map[string]string{}, true}, + {"off", map[string]string{"golem15.yaml": "journal:\n author_pages: false\n"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + _, gdb, reg := bootMenuItemTypes(t, tc.files) + seedLocales(t, gdb) + ctx := context.Background() + past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC) + ada := seedAuthor(t, gdb, "ada", "Ada", "Lovelace", "ada-l") + bob := seedAuthor(t, gdb, "bob", "Bob", "", "") + seedAuthor(t, gdb, "carl", "Carl", "Idle", "carl") + for _, p := range []models.Post{ + {Title: "By Ada", Slug: "by-ada", Content: "x", Published: true, PublishedAt: &past, UserID: &ada}, + {Title: "By Bob", Slug: "by-bob", Content: "x", Published: true, PublishedAt: &past, UserID: &bob}, + {Title: "Carl draft", Slug: "carl-draft", Content: "x", Published: false, PublishedAt: &past}, + } { + p := p + if err := gdb.Create(&p).Error; err != nil { + t.Fatal(err) + } + } + + typ, ok := reg.ContentType(classes.MenuTypeAuthor) + if !ok || typ.Content.Kind != pact.ContentKindProfile || typ.Content.Controller != "" || typ.Content.Model != "Backend\\Models\\User" { + t.Fatalf("journal-author descriptor = %+v", typ.Content) + } + page, err := reg.Records(ctx, classes.MenuTypeAuthor, "", 0, "") + if err != nil || len(page.Records) != 2 || page.Records[0].ID != id(ada) || page.Records[1].ID != id(bob) { + t.Fatalf("Records = %+v, %v; want Ada and Bob only", page, err) + } + rec := describe(t, reg, classes.MenuTypeAuthor, id(ada)) + want := "en= pl=" + if tc.on { + want = "en=/blog/author/ada-l pl=/pl/blog/author/ada-l" + } + if localeURLs(rec) != want || rec.Locales[0].Title != "Ada Lovelace" || !rec.Published { + t.Fatalf("Describe Ada = %+v; want URLs %s", rec, want) + } + if tc.on { + if got := localeURLs(describe(t, reg, classes.MenuTypeAuthor, id(bob))); got != "en=/blog/author/bob pl=/pl/blog/author/bob" { + t.Fatalf("login fallback URLs = %s", got) + } + } + for _, path := range []string{"/blog/author/ada-l", "/pl/blog/author/ada-l"} { + key, res, err := reg.MatchPath(ctx, path, "") + if err != nil { + t.Fatal(err) + } + if tc.on && (key != classes.MenuTypeAuthor || res == nil || !res.Exact || res.ID != id(ada)) { + t.Fatalf("MatchPath(%s) = %q %+v", path, key, res) + } + if !tc.on && res != nil && key == classes.MenuTypeAuthor { + t.Fatalf("MatchPath(%s) matched with author pages off: %+v", path, res) + } + } + if key, res, err := reg.MatchPath(ctx, "/blog/author/carl", ""); err != nil || (res != nil && key == classes.MenuTypeAuthor) { + t.Fatalf("an author without a published post matched: %q %+v %v", key, res, err) + } + item, err := reg.Resolve(ctx, classes.MenuTypeAuthor, id(ada), "", false, "") + if err != nil || (item != nil) != tc.on || (tc.on && item.URL != "/blog/author/ada-l") { + t.Fatalf("Resolve = %+v, %v", item, err) + } + }) + } +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index afb0c5f..0c2306b 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -143,6 +143,7 @@ menuitem: all_journal_posts: All journal posts category_journal_posts: Journal category posts journal_tag: Journal tag + journal_author: Journal author 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 09843b7..254f911 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -95,6 +95,7 @@ menuitem: all_journal_posts: Wszystkie wpisy (Journal) category_journal_posts: Wpisy kategorii (Journal) journal_tag: Tag bloga + journal_author: Autor (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 index d9c058d..907a01b 100644 --- a/menu_item_types.go +++ b/menu_item_types.go @@ -9,7 +9,8 @@ import ( // 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. +// framework's menu item type registry; posts, categories, tags and author +// pages (journal-author) carry content descriptors. func (p *Plugin) MenuItemTypes() []pact.MenuItemType { return classes.MenuItemTypes(func() *backpack.App { if p == nil {