feat: journal-author content type
- journal-author (/blog/author/:author, :author the author slug else the login): references, Resolve, Records of the authors with a published post, Describe as a profile per locale, exact Match on the pattern - golem15.journal.author_pages false resolves nothing, describes empty URLs and never matches
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user