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

View File

@@ -169,7 +169,23 @@ func Show(app *backpack.App) http.HandlerFunc {
payload := serializePost(post, db)
payload["previous_post"] = serializeSibling(previousPost(db, post))
payload["next_post"] = serializeSibling(nextPost(db, post))
related := relatedPosts(db, post, isEditor(pr))
// golem15.journal.related_mode: the default category mode answers as
// it always has; scored (opt-in, P33-44) ranks the related posts by
// shared tags and categories and adds the neighbours within the
// post's first category (additive keys, scored mode only).
relatedCfg := classes.ReadRelatedSettings(appConfig(app))
var related []models.Post
if relatedCfg.Scored() {
related, err = classes.ScoredRelatedPosts(r.Context(), db, post, relatedCfg.Count, isEditor(pr))
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
payload["previous_in_category"] = serializeSibling(classes.CategoryNeighbour(r.Context(), db, post, true))
payload["next_in_category"] = serializeSibling(classes.CategoryNeighbour(r.Context(), db, post, false))
} else {
related = relatedPosts(db, post, isEditor(pr))
}
rows := make([]map[string]any, 0, len(related))
for i := range related {
rows = append(rows, serializePost(&related[i], db))