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:
Jakub Zych
2026-11-04 14:14:09 +01:00
parent 0edf6e1b67
commit ee3cb263dc
5 changed files with 461 additions and 2 deletions

176
classes/related.go Normal file
View 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
}