feat: opt-in scored related posts and category neighbours
- 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
This commit is contained in:
176
classes/related.go
Normal file
176
classes/related.go
Normal file
@@ -0,0 +1,176 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user