The post, category, tag and author descriptors keep one entry per enabled locale but give a locale a URL only when the record has content in it: posts and categories by their own translation (a translated slug alone is no content), tags and authors when one of their published posts is translated into it, the default locale always. Without content the entry's URL is "", so hreflang and the sitemaps never advertise an untranslated locale. The menu item types' Resolve is unchanged.
861 lines
27 KiB
Go
861 lines
27 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"html"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
|
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// The admin controller IDs whose forms edit the described records. They
|
|
// repeat the controllers package constants, which this package cannot
|
|
// import (controllers imports classes).
|
|
const (
|
|
postsControllerID = "golem15.journal.posts"
|
|
categoriesControllerID = "golem15.journal.categories"
|
|
tagsControllerID = "golem15.journal.tags"
|
|
)
|
|
|
|
// contentDB is the database a content descriptor reads: the write
|
|
// transaction in ctx when there is one (cabana.TxFromContext), so code
|
|
// running inside a save sees what the save wrote, else the application's.
|
|
func (m menuTypes) contentDB(ctx context.Context) (*gorm.DB, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if tx, ok := cabana.TxFromContext(ctx); ok {
|
|
return tx.Session(&gorm.Session{NewDB: true, Context: ctx}), nil
|
|
}
|
|
return m.db(ctx)
|
|
}
|
|
|
|
// describePost describes one post, drafts included: Published tells
|
|
// whether visitors see it.
|
|
func (m menuTypes) describePost(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 posts []models.Post
|
|
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(posts) == 0 {
|
|
return nil, nil
|
|
}
|
|
records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &records[0], nil
|
|
}
|
|
|
|
// descriptorFacts are the site settings the descriptors' facts follow.
|
|
type descriptorFacts struct {
|
|
h1ToH2 bool // EffectiveH1ToH2: TopHeading is 2, else 0
|
|
authorPages bool // AuthorPagesKey: authors carry a journal-author reference
|
|
}
|
|
|
|
// facts reads the settings the descriptors follow, on db.
|
|
func (m menuTypes) facts(ctx context.Context, db *gorm.DB) descriptorFacts {
|
|
var cfg *compass.Config
|
|
if m.app != nil {
|
|
if app := m.app(); app != nil {
|
|
cfg = app.Config
|
|
}
|
|
}
|
|
return descriptorFacts{h1ToH2: EffectiveH1ToH2(ctx, db, cfg), authorPages: m.authorPages()}
|
|
}
|
|
|
|
// describePosts describes posts in the given order. Each locale's URL is
|
|
// the alternate resolvePost builds; Title is the translated title,
|
|
// Description the excerpt as plain text, Image the first featured image and
|
|
// Words the body's word count, each translated with the default value as
|
|
// fallback. The record carries its first publication, its categories by
|
|
// name (Parents), its author (with a journal-author reference while author
|
|
// pages are on) and TopHeading (2 while the H1 setting is on, else 0); each
|
|
// locale lists the featured image (alt: the file's title, else the
|
|
// translated post title, P33-06) and the body's images, YouTube embeds and
|
|
// headings (parseContentFacts). A locale in which the post has no content
|
|
// (no translated title, content, content_html or excerpt, P33-64) keeps its
|
|
// entry with URL "" (no public page, D-57). The queries are batched per
|
|
// page.
|
|
func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, pattern, unprefixed string, facts descriptorFacts) ([]pact.ContentRecord, error) {
|
|
in := pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}
|
|
items, err := resolvePostItems(ctx, db, posts, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
locales, err := loadMenuLocales(ctx, db, unprefixed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]uint, 0, len(posts))
|
|
for _, p := range posts {
|
|
ids = append(ids, p.ID)
|
|
}
|
|
tr, err := loadMenuTranslations(ctx, db, models.Post{}.MorphName(), ids, locales)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
featured, err := firstFiles(ctx, db, models.Post{}.MorphName(), "featured_images", ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parents, err := postCategoryRefs(ctx, db, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
userIDs := []uint{}
|
|
for _, p := range posts {
|
|
if p.UserID != nil {
|
|
userIDs = append(userIDs, *p.UserID)
|
|
}
|
|
}
|
|
authors, err := loadAuthors(ctx, db, userIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]pact.ContentRecord, 0, len(posts))
|
|
for i := range posts {
|
|
p := &posts[i]
|
|
rec := pact.ContentRecord{
|
|
ID: strconv.FormatUint(uint64(p.ID), 10),
|
|
Published: postPublished(p),
|
|
LastMod: copyTime(p.UpdatedAt),
|
|
PublishedAt: copyTime(p.PublishedAt),
|
|
Parents: parents[p.ID],
|
|
TopHeading: topHeading(facts.h1ToH2),
|
|
}
|
|
if p.UserID != nil {
|
|
if a, ok := authors[*p.UserID]; ok {
|
|
person := pact.ContentPerson{Name: a.name()}
|
|
if facts.authorPages {
|
|
person.Ref = pact.ContentRef{Type: MenuTypeAuthor, ID: strconv.FormatUint(uint64(a.ID), 10)}
|
|
}
|
|
rec.Authors = []pact.ContentPerson{person}
|
|
}
|
|
}
|
|
file, hasFile := featured[p.ID]
|
|
for _, alt := range items[i].Alternates {
|
|
locale := alt.Locale
|
|
if locale == xDefaultLocale {
|
|
locale = ""
|
|
}
|
|
body := tr.value(locale, p.ID, "content_html", p.ContentHTML)
|
|
if strings.TrimSpace(body) == "" {
|
|
body = tr.value(locale, p.ID, "content", p.Content)
|
|
}
|
|
words := wordCount(plainText(body))
|
|
found := parseContentFacts(body)
|
|
var images []pact.ContentImage
|
|
image := ""
|
|
if hasFile {
|
|
image = file.URL()
|
|
altText := alt.Title
|
|
if file.Title != nil && strings.TrimSpace(*file.Title) != "" {
|
|
altText = strings.TrimSpace(*file.Title)
|
|
}
|
|
images = append(images, pact.ContentImage{URL: image, Alt: altText})
|
|
}
|
|
images = append(images, found.images...)
|
|
url := alt.URL
|
|
if !tr.hasContent(locales, alt.Locale, p.ID, postContentFields) {
|
|
// No translation in this locale (P33-64, D-57): no public
|
|
// page for it, so no hreflang or sitemap address.
|
|
url = ""
|
|
}
|
|
rec.Locales = append(rec.Locales, pact.ContentLocale{
|
|
Locale: alt.Locale,
|
|
URL: url,
|
|
Title: alt.Title,
|
|
Description: strings.TrimSpace(plainText(tr.value(locale, p.ID, "excerpt", p.Excerpt))),
|
|
Image: image,
|
|
Words: &words,
|
|
Images: images,
|
|
Videos: found.videos,
|
|
Headings: found.headings,
|
|
})
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// postPublished is publishedPosts for one loaded post.
|
|
func postPublished(p *models.Post) bool {
|
|
return p.Published && p.PublishedAt != nil && p.PublishedAt.Before(time.Now())
|
|
}
|
|
|
|
// firstImages maps each record id to the public URL of its first file in
|
|
// field (sort order, then id).
|
|
func firstImages(ctx context.Context, db *gorm.DB, morph, field string, ids []uint) (map[uint]string, error) {
|
|
files, err := firstFiles(ctx, db, morph, field, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[uint]string, len(files))
|
|
for id, f := range files {
|
|
out[id] = f.URL()
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// firstFiles maps each record id to its first file in field (sort order,
|
|
// then id).
|
|
func firstFiles(ctx context.Context, db *gorm.DB, morph, field string, ids []uint) (map[uint]attach.File, error) {
|
|
out := map[uint]attach.File{}
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
keys := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
keys = append(keys, strconv.FormatUint(uint64(id), 10))
|
|
}
|
|
var files []attach.File
|
|
err := db.WithContext(ctx).
|
|
Where("attachment_type = ? AND field = ? AND attachment_id IN ?", morph, field, keys).
|
|
Order("sort_order ASC, id ASC").
|
|
Find(&files).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range files {
|
|
id, err := strconv.ParseUint(files[i].AttachmentID, 10, 32)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if _, seen := out[uint(id)]; !seen {
|
|
out[uint(id)] = files[i]
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func copyTime(t *time.Time) *time.Time {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
v := *t
|
|
return &v
|
|
}
|
|
|
|
// plainText drops HTML tags and unescapes entities, with whitespace runs
|
|
// collapsed to one space.
|
|
func plainText(s string) string {
|
|
var b strings.Builder
|
|
inTag := false
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '<':
|
|
inTag = true
|
|
b.WriteRune(' ')
|
|
case r == '>':
|
|
inTag = false
|
|
case !inTag:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return strings.Join(strings.Fields(html.UnescapeString(b.String())), " ")
|
|
}
|
|
|
|
// wordCount counts the whitespace-separated words of s.
|
|
func wordCount(s string) int {
|
|
n := 0
|
|
in := false
|
|
for _, r := range s {
|
|
if unicode.IsSpace(r) {
|
|
in = false
|
|
continue
|
|
}
|
|
if !in {
|
|
n++
|
|
in = true
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// TagPagesKey is the host configuration key that says whether the site has
|
|
// tag pages (default true). With false, journal-tag items resolve to
|
|
// nothing, tag descriptions carry empty URLs and tag paths never match.
|
|
const TagPagesKey = "golem15.journal.tag_pages"
|
|
|
|
// maxContentPage caps the page size of a descriptor's Records; a limit of 0
|
|
// or below asks for the cap.
|
|
const maxContentPage = 500
|
|
|
|
func pageLimit(n int) int {
|
|
if n <= 0 || n > maxContentPage {
|
|
return maxContentPage
|
|
}
|
|
return n
|
|
}
|
|
|
|
// afterID parses a Records After cursor: "" or an invalid value is the
|
|
// start.
|
|
func afterID(after string) uint {
|
|
id, _ := menuReferenceID(after)
|
|
return id
|
|
}
|
|
|
|
// AuthorPagesKey is the host configuration key that says whether the site
|
|
// has author pages (default true). With false, journal-author items resolve
|
|
// to nothing, author descriptions carry empty URLs, author paths never
|
|
// match and post authors carry no journal-author reference.
|
|
const AuthorPagesKey = "golem15.journal.author_pages"
|
|
|
|
// authorPages reports AuthorPagesKey, true when unset.
|
|
func (m menuTypes) authorPages() bool {
|
|
if m.app == nil {
|
|
return true
|
|
}
|
|
app := m.app()
|
|
if app == nil || app.Config == nil || !app.Config.Has(AuthorPagesKey) {
|
|
return true
|
|
}
|
|
return app.Config.Bool(AuthorPagesKey)
|
|
}
|
|
|
|
// tagPages reports TagPagesKey, true when unset.
|
|
func (m menuTypes) tagPages() bool {
|
|
if m.app == nil {
|
|
return true
|
|
}
|
|
app := m.app()
|
|
if app == nil || app.Config == nil || !app.Config.Has(TagPagesKey) {
|
|
return true
|
|
}
|
|
return app.Config.Bool(TagPagesKey)
|
|
}
|
|
|
|
// listPosts is the Records of journal-post: every post, drafts included, in
|
|
// id order.
|
|
func (m menuTypes) listPosts(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 posts []models.Post
|
|
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&posts).Error; err != nil {
|
|
return pact.ContentPage{}, err
|
|
}
|
|
more := len(posts) > limit
|
|
if more {
|
|
posts = posts[:limit]
|
|
}
|
|
records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db))
|
|
if err != nil {
|
|
return pact.ContentPage{}, err
|
|
}
|
|
return contentPage(records, more), nil
|
|
}
|
|
|
|
func contentPage(records []pact.ContentRecord, more bool) pact.ContentPage {
|
|
page := pact.ContentPage{Records: records}
|
|
if page.Records == nil {
|
|
page.Records = []pact.ContentRecord{}
|
|
}
|
|
if more && len(records) > 0 {
|
|
page.Next = records[len(records)-1].ID
|
|
}
|
|
return page
|
|
}
|
|
|
|
// matchPost is the Match of journal-post. The post is found by its slug in
|
|
// the path's locale, else its default slug, else a slug of another locale,
|
|
// else by :id; drafts never match. The match is exact only when the path is
|
|
// the post's own URL in that locale, so every segment (category, date, id,
|
|
// locale prefix, slug) must fit the post.
|
|
func (m menuTypes) matchPost(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
|
db, err := m.contentDB(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
morph := models.Post{}.MorphName()
|
|
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
|
|
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
|
|
return publishedPosts(q.Model(&models.Post{}))
|
|
})
|
|
}
|
|
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
|
|
var posts []models.Post
|
|
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil || len(posts) == 0 {
|
|
return nil, err
|
|
}
|
|
items, err := resolvePostItems(ctx, db, posts, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return items[0].Alternates, nil
|
|
}
|
|
return matchContent(ctx, db, in, find, urls)
|
|
}
|
|
|
|
func (m menuTypes) describeCategory(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 cats []models.Category
|
|
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(cats) == 0 {
|
|
return nil, nil
|
|
}
|
|
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &records[0], nil
|
|
}
|
|
|
|
func (m menuTypes) listCategories(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 cats []models.Category
|
|
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&cats).Error; err != nil {
|
|
return pact.ContentPage{}, err
|
|
}
|
|
more := len(cats) > limit
|
|
if more {
|
|
cats = cats[:limit]
|
|
}
|
|
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db))
|
|
if err != nil {
|
|
return pact.ContentPage{}, err
|
|
}
|
|
return contentPage(records, more), nil
|
|
}
|
|
|
|
// describeCategories describes categories, always published: each locale's
|
|
// URL and title as resolveCategory builds them, the translated description
|
|
// as plain text and the word count of the category's content. A child
|
|
// category's Parents is its parent; every locale carries the pillar text's
|
|
// heading outline (content is not translatable) and TopHeading follows the
|
|
// H1 setting (2 on, 0 off), so the pillar text is checked as post content
|
|
// is (CONTEXT 2026-10-28 H1 conversion scope).
|
|
// A locale in which the category has no translated name or description
|
|
// (P33-64) gets URL "".
|
|
func describeCategories(ctx context.Context, db *gorm.DB, cats []models.Category, pattern, unprefixed string, facts descriptorFacts) ([]pact.ContentRecord, error) {
|
|
ids := make([]uint, 0, len(cats))
|
|
for _, c := range cats {
|
|
ids = append(ids, c.ID)
|
|
}
|
|
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]pact.ContentRecord, 0, len(cats))
|
|
for _, c := range cats {
|
|
item := ci.item(c)
|
|
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(c.ID), 10), Published: true, LastMod: copyTime(c.UpdatedAt),
|
|
TopHeading: topHeading(facts.h1ToH2)}
|
|
if c.ParentID != nil && *c.ParentID != 0 && *c.ParentID != c.ID {
|
|
rec.Parents = []pact.ContentRef{{Type: MenuTypeCategory, ID: strconv.FormatUint(uint64(*c.ParentID), 10)}}
|
|
}
|
|
var words *int
|
|
var headings []pact.ContentHeading
|
|
if c.Content != nil && strings.TrimSpace(*c.Content) != "" {
|
|
n := wordCount(plainText(*c.Content))
|
|
words = &n
|
|
headings = parseContentFacts(*c.Content).headings
|
|
}
|
|
host := ""
|
|
if c.Description != nil {
|
|
host = *c.Description
|
|
}
|
|
for _, alt := range item.Alternates {
|
|
locale := alt.Locale
|
|
if locale == xDefaultLocale {
|
|
locale = ""
|
|
}
|
|
url := alt.URL
|
|
if !ci.tr.hasContent(ci.locales, alt.Locale, c.ID, categoryContentFields) {
|
|
url = ""
|
|
}
|
|
rec.Locales = append(rec.Locales, pact.ContentLocale{
|
|
Locale: alt.Locale,
|
|
URL: url,
|
|
Title: alt.Title,
|
|
Description: plainText(ci.tr.value(locale, c.ID, "description", host)),
|
|
Words: words,
|
|
Headings: headings,
|
|
})
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// matchCategory is the Match of journal-category, by translated or default
|
|
// slug or :id, exact only on the category's own URL in the path's locale.
|
|
func (m menuTypes) matchCategory(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
|
db, err := m.contentDB(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
morph := models.Category{}.MorphName()
|
|
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
|
|
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
|
|
return q.Model(&models.Category{})
|
|
})
|
|
}
|
|
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
|
|
var cats []models.Category
|
|
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil || len(cats) == 0 {
|
|
return nil, err
|
|
}
|
|
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale}, []uint{id})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ci.item(cats[0]).Alternates, nil
|
|
}
|
|
return matchContent(ctx, db, in, find, urls)
|
|
}
|
|
|
|
// tagReferences lists every tag by name, ties by id.
|
|
func (m menuTypes) tagReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
|
|
db, err := m.db(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var tags []models.Tag
|
|
if err := db.Order("name ASC, id ASC").Find(&tags).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
refs := make([]pact.MenuItemReference, 0, len(tags))
|
|
for _, tag := range tags {
|
|
refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(tag.ID), 10), Label: tag.Name})
|
|
}
|
|
return refs, nil
|
|
}
|
|
|
|
// resolveTag resolves one journal-tag item; an unknown tag, and any tag on a
|
|
// host without tag pages, resolve to nothing.
|
|
func (m menuTypes) resolveTag(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
id, ok := menuReferenceID(in.Reference)
|
|
if !ok || !m.tagPages() {
|
|
return nil, nil
|
|
}
|
|
db, err := m.db(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var tags []models.Tag
|
|
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(tags) == 0 {
|
|
return nil, nil
|
|
}
|
|
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tag := tags[0]
|
|
item := resolvedMenuItem(in.Pattern, locales, tag.Name, tag.UpdatedAt, tagParams(tag), func(string) string { return tag.Name })
|
|
return &item, nil
|
|
}
|
|
|
|
// tagParams are a tag's placeholder values; tags are not translatable.
|
|
func tagParams(tag models.Tag) func(string) [][2]string {
|
|
return func(string) [][2]string {
|
|
return [][2]string{{"id", strconv.FormatUint(uint64(tag.ID), 10)}, {"slug", tag.Slug}}
|
|
}
|
|
}
|
|
|
|
func (m menuTypes) describeTag(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 tags []models.Tag
|
|
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(tags) == 0 {
|
|
return nil, nil
|
|
}
|
|
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &records[0], nil
|
|
}
|
|
|
|
func (m menuTypes) listTags(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 tags []models.Tag
|
|
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&tags).Error; err != nil {
|
|
return pact.ContentPage{}, err
|
|
}
|
|
more := len(tags) > limit
|
|
if more {
|
|
tags = tags[:limit]
|
|
}
|
|
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
|
|
if err != nil {
|
|
return pact.ContentPage{}, err
|
|
}
|
|
return contentPage(records, more), nil
|
|
}
|
|
|
|
// describeTags describes tags, always published, one locale per enabled
|
|
// locale with the same name and slug; URLs are empty without tag pages, and
|
|
// in a locale in which none of the tag's published posts has content
|
|
// (P33-64).
|
|
func (m menuTypes) describeTags(ctx context.Context, db *gorm.DB, tags []models.Tag, pattern, unprefixed string) ([]pact.ContentRecord, error) {
|
|
locales, err := loadMenuLocales(ctx, db, unprefixed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pages := m.tagPages()
|
|
ids := make([]uint, 0, len(tags))
|
|
for _, tag := range tags {
|
|
ids = append(ids, tag.ID)
|
|
}
|
|
content, err := tagContentLocales(ctx, db, locales, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]pact.ContentRecord, 0, len(tags))
|
|
for _, tag := range tags {
|
|
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(tag.ID), 10), Published: true, LastMod: copyTime(tag.UpdatedAt)}
|
|
desc := ""
|
|
if tag.Description != nil {
|
|
desc = plainText(*tag.Description)
|
|
}
|
|
for _, alt := range menuAlternates(pattern, locales, tagParams(tag), func(string) string { return tag.Name }) {
|
|
url := alt.URL
|
|
if !pages || !ownerHasContent(locales, content, tag.ID, alt.Locale) {
|
|
url = ""
|
|
}
|
|
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: alt.Locale, URL: url, Title: tag.Name, Description: desc})
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// matchTag is the Match of journal-tag, by slug or :id; nothing matches on
|
|
// a host without tag pages.
|
|
func (m menuTypes) matchTag(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
|
if !m.tagPages() {
|
|
return nil, nil
|
|
}
|
|
db, err := m.contentDB(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
find := func(values map[string]string, _ string, _ menuLocales) (uint, error) {
|
|
q := db.Model(&models.Tag{})
|
|
switch {
|
|
case values["slug"] != "":
|
|
q = q.Where("slug = ?", values["slug"])
|
|
case values["id"] != "":
|
|
id, ok := menuReferenceID(values["id"])
|
|
if !ok {
|
|
return 0, nil
|
|
}
|
|
q = q.Where("id = ?", id)
|
|
default:
|
|
return 0, nil
|
|
}
|
|
var ids []uint
|
|
if err := q.Order("id ASC").Limit(1).Pluck("id", &ids).Error; err != nil || len(ids) == 0 {
|
|
return 0, err
|
|
}
|
|
return ids[0], nil
|
|
}
|
|
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
|
|
var tags []models.Tag
|
|
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil || len(tags) == 0 {
|
|
return nil, err
|
|
}
|
|
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tag := tags[0]
|
|
return menuAlternates(in.Pattern, locales, tagParams(tag), func(string) string { return tag.Name }), nil
|
|
}
|
|
return matchContent(ctx, db, in, find, urls)
|
|
}
|
|
|
|
// pathReading is one way to read a public path: the locale it addresses,
|
|
// the path without its locale prefix, and whether the prefix is the one the
|
|
// locale's URLs carry.
|
|
type pathReading struct {
|
|
locale string
|
|
rest string
|
|
prefixOK bool
|
|
}
|
|
|
|
// readPath lists the readings of path: with a prefix of an enabled locale
|
|
// other than the unprefixed one, with the unprefixed locale's own code as a
|
|
// prefix (stale, never exact), and as a path of the unprefixed locale.
|
|
func readPath(path string, locales menuLocales) []pathReading {
|
|
out := []pathReading{}
|
|
seg, rest := path, "/"
|
|
if trimmed := strings.TrimPrefix(path, "/"); trimmed != path {
|
|
if i := strings.IndexByte(trimmed, '/'); i >= 0 {
|
|
seg, rest = trimmed[:i], trimmed[i:]
|
|
} else {
|
|
seg = trimmed
|
|
}
|
|
for _, code := range locales.codes {
|
|
if seg == code {
|
|
out = append(out, pathReading{locale: code, rest: rest, prefixOK: code != locales.unprefixed})
|
|
}
|
|
}
|
|
}
|
|
return append(out, pathReading{locale: locales.unprefixed, rest: path, prefixOK: true})
|
|
}
|
|
|
|
// matchContent reads the path in every way readPath lists, finds the record
|
|
// its placeholder values name (find) and compares the path with the
|
|
// record's URL in the reading's locale (urls). The first exact reading
|
|
// wins, else the first that found a record.
|
|
func matchContent(ctx context.Context, db *gorm.DB, in pact.ContentMatch, find func(values map[string]string, locale string, locales menuLocales) (uint, error), urls func(id uint) ([]pact.MenuItemAlternate, error)) (*pact.ContentMatchResult, error) {
|
|
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var fallback *pact.ContentMatchResult
|
|
for _, reading := range readPath(in.Path, locales) {
|
|
values, ok := signpost.MatchPattern(in.Pattern, reading.rest)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id, err := find(values, reading.locale, locales)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if id == 0 {
|
|
continue
|
|
}
|
|
alternates, err := urls(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
exact := false
|
|
for _, alt := range alternates {
|
|
if alt.Locale == reading.locale || (len(locales.codes) == 0 && alt.Locale == xDefaultLocale) {
|
|
exact = reading.prefixOK && alt.URL == in.Path
|
|
break
|
|
}
|
|
}
|
|
res := &pact.ContentMatchResult{ID: strconv.FormatUint(uint64(id), 10), Locale: reading.locale, Exact: exact}
|
|
if exact {
|
|
return res, nil
|
|
}
|
|
if fallback == nil {
|
|
fallback = res
|
|
}
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
// findMatchRecord finds the record a path's values name among scope: by
|
|
// :slug, first the slug of the path's locale (translate's slug index), then
|
|
// the default slug, then a slug of any other locale; else by :id. A value
|
|
// reaches the database only as a query parameter.
|
|
func findMatchRecord(ctx context.Context, db *gorm.DB, values map[string]string, locale string, locales menuLocales, morph string, scope func(*gorm.DB) *gorm.DB) (uint, error) {
|
|
first := func(ids []uint) (uint, error) {
|
|
if len(ids) == 0 {
|
|
return 0, nil
|
|
}
|
|
var found []uint
|
|
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("id IN ?", ids).Order("id ASC").Limit(1).Pluck("id", &found).Error; err != nil || len(found) == 0 {
|
|
return 0, err
|
|
}
|
|
return found[0], nil
|
|
}
|
|
if slug := values["slug"]; slug != "" {
|
|
if locale != "" && locale != locales.defaultCode {
|
|
ids, err := slugIndexIDs(ctx, db, morph, slug, locale)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if id, err := first(ids); err != nil || id != 0 {
|
|
return id, err
|
|
}
|
|
}
|
|
var hosts []uint
|
|
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("slug = ?", slug).Order("id ASC").Pluck("id", &hosts).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
if id, err := first(hosts); err != nil || id != 0 {
|
|
return id, err
|
|
}
|
|
ids, err := slugIndexIDs(ctx, db, morph, slug, "")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return first(ids)
|
|
}
|
|
if raw := values["id"]; raw != "" {
|
|
id, ok := menuReferenceID(raw)
|
|
if !ok {
|
|
return 0, nil
|
|
}
|
|
return first([]uint{id})
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// slugIndexIDs are the ids of morph records whose translated slug is slug
|
|
// in locale ("" for any locale), from translate's index table.
|
|
func slugIndexIDs(ctx context.Context, db *gorm.DB, morph, slug, locale string) ([]uint, error) {
|
|
q := db.WithContext(ctx).Table(translatemodels.Index{}.TableName()).
|
|
Where("model_type = ? AND item = ? AND value = ?", morph, "slug", slug)
|
|
if locale != "" {
|
|
q = q.Where("locale = ?", locale)
|
|
}
|
|
var raw []string
|
|
if err := q.Order("id ASC").Pluck("model_id", &raw).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]uint, 0, len(raw))
|
|
for _, r := range raw {
|
|
if id, ok := menuReferenceID(r); ok {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
return ids, nil
|
|
}
|