- golem15.journal.related_mode (category | scored, default category) and
golem15.journal.related_count (3 to 6, default 3, clamped) in
config/config.yaml; classes.ReadRelatedSettings reads them
- the default mode answers GET /posts/{slug} exactly as before
- scored: related posts ranked by 2 points per shared tag plus 1 per
shared category, ties by newest, in one scoring query
(classes.ScoredRelatedPosts), published only for visitors; the answer
adds previous_in_category and next_in_category, the neighbours by
publish time within the post's first category by name
(classes.CategoryNeighbour), in the direction of previous_post and
next_post
- TestRelatedDefaultUnchanged, TestRelatedScored (a draft and a future
post sharing every tag never shown) and TestRelatedScoredCountClamped
177 lines
6.5 KiB
Go
177 lines
6.5 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Related posts and category neighbours of a post (D-82, P33-44). Opt-in:
|
|
// golem15.journal.related_mode is "category" by default, which keeps the
|
|
// post answer as it has always been (posts sharing a category, three,
|
|
// newest first). "scored" ranks the related posts by shared tags and shared
|
|
// categories and adds the neighbours within the post's first category.
|
|
const (
|
|
// RelatedModeKey is the config key of the related-posts mode.
|
|
RelatedModeKey = "golem15.journal.related_mode"
|
|
// RelatedCountKey is the config key of the number of scored related
|
|
// posts, clamped to RelatedCountMin..RelatedCountMax.
|
|
RelatedCountKey = "golem15.journal.related_count"
|
|
|
|
// RelatedModeCategory is the default: today's related posts.
|
|
RelatedModeCategory = "category"
|
|
// RelatedModeScored ranks by shared tags and categories.
|
|
RelatedModeScored = "scored"
|
|
|
|
RelatedCountDefault = 3
|
|
RelatedCountMin = 3
|
|
RelatedCountMax = 6
|
|
|
|
// Points per shared tag and per shared category.
|
|
relatedTagPoints = 2
|
|
relatedCategoryPoints = 1
|
|
)
|
|
|
|
// RelatedSettings is the related-posts configuration.
|
|
type RelatedSettings struct {
|
|
// Mode is RelatedModeCategory or RelatedModeScored.
|
|
Mode string
|
|
// Count is the number of scored related posts (3 to 6).
|
|
Count int
|
|
}
|
|
|
|
// Scored reports whether the scored mode is on.
|
|
func (s RelatedSettings) Scored() bool { return s.Mode == RelatedModeScored }
|
|
|
|
// ReadRelatedSettings reads golem15.journal.related_mode (anything but
|
|
// "scored" is the category mode) and golem15.journal.related_count (unset
|
|
// or not a number: 3; otherwise clamped to 3..6).
|
|
func ReadRelatedSettings(cfg *compass.Config) RelatedSettings {
|
|
out := RelatedSettings{Mode: RelatedModeCategory, Count: RelatedCountDefault}
|
|
if cfg == nil {
|
|
return out
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(cfg.String(RelatedModeKey)), RelatedModeScored) {
|
|
out.Mode = RelatedModeScored
|
|
}
|
|
if n := cfg.Int(RelatedCountKey); n > 0 {
|
|
out.Count = min(RelatedCountMax, max(RelatedCountMin, n))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// publishedClause is the visitor rule of the public API (applyPublished in
|
|
// controllers/api): published, with a publish time in the past.
|
|
const publishedClause = "p.published = ? AND p.published_at IS NOT NULL AND p.published_at < ?"
|
|
|
|
// ScoredRelatedPosts is the scored mode's related posts of post: other posts
|
|
// sharing a tag or a category, ranked by 2 points per shared tag plus 1
|
|
// point per shared category, ties by the newest publish time (then the
|
|
// higher id), at most count. Posts that share nothing are never related.
|
|
// Visitors see published posts only; editors (includeDrafts) also see
|
|
// drafts, as the category mode's related posts do. One query computes the
|
|
// scores; the rows come back with their author, categories and tags.
|
|
func ScoredRelatedPosts(ctx context.Context, db *gorm.DB, post *models.Post, count int, includeDrafts bool) ([]models.Post, error) {
|
|
if post == nil || count <= 0 {
|
|
return []models.Post{}, nil
|
|
}
|
|
db = db.WithContext(ctx)
|
|
q := db.Table("golem15_journal_posts AS p").
|
|
Select("p.id, ? * COALESCE(st.n, 0) + ? * COALESCE(sc.n, 0) AS score", relatedTagPoints, relatedCategoryPoints).
|
|
Joins(`LEFT JOIN (SELECT post_id, COUNT(*) AS n FROM golem15_journal_posts_tags
|
|
WHERE tag_id IN (SELECT tag_id FROM golem15_journal_posts_tags WHERE post_id = ?)
|
|
GROUP BY post_id) st ON st.post_id = p.id`, post.ID).
|
|
Joins(`LEFT JOIN (SELECT post_id, COUNT(*) AS n FROM golem15_journal_posts_categories
|
|
WHERE category_id IN (SELECT category_id FROM golem15_journal_posts_categories WHERE post_id = ?)
|
|
GROUP BY post_id) sc ON sc.post_id = p.id`, post.ID).
|
|
Where("p.id <> ?", post.ID).
|
|
Where("(st.n > 0 OR sc.n > 0)")
|
|
if !includeDrafts {
|
|
q = q.Where(publishedClause, true, time.Now())
|
|
}
|
|
var ranked []struct {
|
|
ID uint
|
|
Score int
|
|
}
|
|
if err := q.Order("score DESC, p.published_at DESC NULLS LAST, p.id DESC").Limit(count).Scan(&ranked).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(ranked) == 0 {
|
|
return []models.Post{}, nil
|
|
}
|
|
ids := make([]uint, 0, len(ranked))
|
|
for _, r := range ranked {
|
|
ids = append(ids, r.ID)
|
|
}
|
|
var rows []models.Post
|
|
if err := db.Model(&models.Post{}).Preload("User").Preload("Categories").Preload("Tags").
|
|
Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
byID := make(map[uint]models.Post, len(rows))
|
|
for _, r := range rows {
|
|
byID[r.ID] = r
|
|
}
|
|
out := make([]models.Post, 0, len(ids))
|
|
for _, id := range ids {
|
|
if r, ok := byID[id]; ok {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// PrimaryCategoryID is the post's first category by name, ties by id (the
|
|
// order the :category URL param takes its category in); 0 without one.
|
|
func PrimaryCategoryID(ctx context.Context, db *gorm.DB, postID uint) uint {
|
|
var rows []struct{ ID uint }
|
|
err := db.WithContext(ctx).Table("golem15_journal_categories AS c").
|
|
Select("c.id").
|
|
Joins("JOIN golem15_journal_posts_categories AS pc ON pc.category_id = c.id").
|
|
Where("pc.post_id = ?", postID).
|
|
Order("c.name ASC, c.id ASC").
|
|
Limit(1).
|
|
Scan(&rows).Error
|
|
if err != nil || len(rows) == 0 {
|
|
return 0
|
|
}
|
|
return rows[0].ID
|
|
}
|
|
|
|
// CategoryNeighbour is the post's neighbour by publish time within its
|
|
// first category, in the direction of the post answer's previous_post and
|
|
// next_post: previous is the nearest published post published after it,
|
|
// next the nearest one published before it (ties by id). nil without a
|
|
// category or a neighbour. Published posts only, as for previous_post.
|
|
func CategoryNeighbour(ctx context.Context, db *gorm.DB, post *models.Post, previous bool) *models.Post {
|
|
if post == nil {
|
|
return nil
|
|
}
|
|
category := PrimaryCategoryID(ctx, db, post.ID)
|
|
if category == 0 {
|
|
return nil
|
|
}
|
|
op, dir := "<", "DESC"
|
|
if previous {
|
|
op, dir = ">", "ASC"
|
|
}
|
|
// The same query shape as the site-wide neighbours (siblingPost), plus
|
|
// the category, so the row is read the same way.
|
|
q := db.WithContext(ctx).Model(&models.Post{}).
|
|
Where("published = ? AND published_at IS NOT NULL AND published_at < ?", true, time.Now()).
|
|
Where("id <> ?", post.ID).
|
|
Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id = ?)", category)
|
|
if post.PublishedAt != nil {
|
|
q = q.Where("(published_at "+op+" ?) OR (published_at = ? AND id "+op+" ?)", post.PublishedAt, post.PublishedAt, post.ID)
|
|
}
|
|
var out models.Post
|
|
if err := q.Order("published_at " + dir + ", id " + dir).Take(&out).Error; err != nil {
|
|
return nil
|
|
}
|
|
return &out
|
|
}
|