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:
25
README.md
25
README.md
@@ -185,6 +185,15 @@ Journal decides per site whether its rich text may hold an H1 (2026-10-28, D-50)
|
||||
- **Settings API.** `GET` and `PUT /_journal/api/v1/settings` read and write every settings column for `golem15.journal.manage_settings` (`403` without it); `PUT` checks the submitted columns against `models.Settings.Rules` and ignores other keys.
|
||||
- **Image alt.** `POST /_journal/api/v1/images/alt` `{dry_run, changes: [{post_id, locale, src, alt}]}` sets the alt of the image whose `src` equals `src` in the post's `content_html` (and in `content` when that holds the same image), in the default locale or in `locale`; `alt: ""` marks it decorative. It needs `golem15.journal.access_posts` and edit rights on each post, refuses the whole batch with `422` when one image is not in its post, answers `{changes: [{post_id, locale, src, old_alt, alt, changed}], dry_run}` and fires a `RecordSaved` per changed post. Alt text is post content, so the bulk alt tool is Journal's (`journal_set_image_alt`), not the SEO plugin's.
|
||||
|
||||
### Related posts
|
||||
|
||||
`GET /_journal/api/v1/posts/{slug}` answers `related_posts` with the post's related posts and `previous_post` and `next_post` with its site-wide neighbours by publish time. The ranking is opt-in (D-82; `classes.ReadRelatedSettings`):
|
||||
|
||||
- **`golem15.journal.related_mode: category`** (the default): as before, up to three posts sharing a category with the post, newest first; none for a post without a category. The answer is unchanged.
|
||||
- **`scored`**: 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, `golem15.journal.related_count` of them (3 to 6, clamped; 3 when unset). A post that shares nothing is never related. One query computes the scores (`classes.ScoredRelatedPosts`). The answer adds `previous_in_category` and `next_in_category` (`{id, title, slug}` or `null`): the neighbours by publish time within the post's first category by name (`classes.PrimaryCategoryID`, `classes.CategoryNeighbour`), in the direction of `previous_post` and `next_post` (previous is the nearest post published after it, next the nearest one published before it).
|
||||
|
||||
Visitors only ever see published posts with a past publish time; an editor who may see drafts also gets drafts among the scored related posts, as the default mode does. The neighbours are published posts only.
|
||||
|
||||
### Personal API tokens
|
||||
|
||||
The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`):
|
||||
@@ -377,6 +386,13 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
||||
|-----|----------------------|-------|
|
||||
| `golem15.journal.content_h1_to_h2` | `SUMMER_GOLEM15__JOURNAL__CONTENT_H1_TO_H2` | The H1 setting used only when the Journal settings row does not exist, default `true` (`config/config.yaml`). The row's `content_h1_to_h2` (Settings › Journal, Headings and links) decides otherwise; `classes.EffectiveH1ToH2` is the one reader. |
|
||||
|
||||
### Related posts
|
||||
|
||||
| Key | Environment variable | Value |
|
||||
|-----|----------------------|-------|
|
||||
| `golem15.journal.related_mode` | `SUMMER_GOLEM15__JOURNAL__RELATED_MODE` | `category` (default, `config/config.yaml`): related posts as before. `scored`: ranked by shared tags and categories, plus `previous_in_category` and `next_in_category` on the post answer. See [related posts](#related-posts). |
|
||||
| `golem15.journal.related_count` | `SUMMER_GOLEM15__JOURNAL__RELATED_COUNT` | The number of scored related posts, default `3`, clamped to 3 to 6. Read in `scored` mode only. |
|
||||
|
||||
### Author pages
|
||||
|
||||
| Key | Environment variable | Value |
|
||||
@@ -396,7 +412,7 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
||||
| `journal.Plugin` | Compiled plugin; `ID` is `golem15.journal`. |
|
||||
| `journal.Plugin.Requires` | Returns `golem15.translate` only. |
|
||||
| `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. |
|
||||
| `journal.Plugin.ConfigFS` | Ships `config/config.yaml` (`golem15.journal.content_h1_to_h2`). |
|
||||
| `journal.Plugin.ConfigFS` | Ships `config/config.yaml` (`golem15.journal.content_h1_to_h2`, `golem15.journal.related_mode`, `golem15.journal.related_count`). |
|
||||
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
|
||||
| `journal.Plugin.Models` | Models registered by this plugin. |
|
||||
| `journal.Plugin.Commands` | Registers `journal:export-posts`, `journal:import-posts`, `journal:reindex` and `journal:fix-headings`. |
|
||||
@@ -431,6 +447,11 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
||||
| `classes.EditorH1Converted`, `classes.EditorH1ConvertedCategory` | The phrase keys of the H1 conversion notice for a post and for a category. |
|
||||
| `classes.H1ToH2Key` | `golem15.journal.content_h1_to_h2`, the config key of the H1 setting when no settings row exists. |
|
||||
| `classes.EffectiveH1ToH2` | The one reader of the H1 setting: the settings row, else the config key, else true. |
|
||||
| `classes.RelatedModeKey`, `classes.RelatedCountKey` | `golem15.journal.related_mode` and `golem15.journal.related_count`. |
|
||||
| `classes.RelatedModeCategory`, `classes.RelatedModeScored`, `classes.RelatedCountDefault`, `classes.RelatedCountMin`, `classes.RelatedCountMax` | The related-posts modes and the count's default and bounds. |
|
||||
| `classes.RelatedSettings`, `classes.ReadRelatedSettings` | The related-posts mode and count read from the config (`Scored` reports the scored mode). |
|
||||
| `classes.ScoredRelatedPosts` | The scored mode's related posts: 2 points per shared tag plus 1 per shared category, ties by newest, at most the count, published only unless drafts are allowed. |
|
||||
| `classes.PrimaryCategoryID`, `classes.CategoryNeighbour` | The post's first category by name, and its previous or next published post by publish time within that category. |
|
||||
| `classes.GenericAnchors`, `classes.DefaultGenericAnchors` | The generic link texts: the settings row's list, one per line, lowercased, else the default English and Polish lists. |
|
||||
| `classes.ConvertH1` | Turns every h1 of HTML into an h2 on the tokenizer, keeping every other byte; returns the converted count. |
|
||||
| `classes.CountH1` | The number of h1 start tags `ConvertH1` would convert. |
|
||||
@@ -508,6 +529,8 @@ Headings, links and media (Phase 33): `TestHeadingConversionTracer`, `TestHeadin
|
||||
|
||||
`TestJournalMCPSmoke` (root package, Docker-free) pins the eleven tools and the prompt (guard, permission, destructive delete, the prompt's arguments and text) and checks that each tool's method and path is a route the plugin registers; `TestOptionalBackendPrincipalDispatch` (`controllers/api`) checks that the list and show handlers see a dispatched backend principal and stay anonymous without one. `TestJournalPromptWrites` (root package, Docker-free) checks that `draft_post` declares `Writes` and, through a served pier endpoint, that a read-only grant's `prompts/list` omits `journal_draft_post` while a read and write grant lists it.
|
||||
|
||||
Related posts: `TestRelatedDefaultUnchanged` (the default and the explicit category mode answer as before, without the scored keys), `TestRelatedScored` (ranking, ties, a draft and a future post sharing every tag and category never shown, the category neighbours) and `TestRelatedScoredCountClamped` (`related_media_test.go`) run on Postgres.
|
||||
|
||||
`TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule.
|
||||
|
||||
The site cache purge has unit tests of the deprecated keys in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`; the request rules are tested in the framework's cooler) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache` on the deprecated keys, `TestSiteCacheFrameworkKeysWin` with both sets) that point the plugin at a local receiver and check one authorized request after each commit.
|
||||
|
||||
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
|
||||
}
|
||||
@@ -8,3 +8,13 @@
|
||||
# H1 to H4. Changing it never rewrites stored content; journal:fix-headings
|
||||
# --apply does.
|
||||
content_h1_to_h2: true
|
||||
|
||||
# Related posts on GET /_journal/api/v1/posts/{slug} (opt-in).
|
||||
# related_mode "category" (the default) answers as before: up to three
|
||||
# posts sharing a category with the post, newest first. "scored" ranks
|
||||
# posts by shared tags (2 points each) and shared categories (1 point
|
||||
# each), ties by the newest, returns related_count of them (3 to 6,
|
||||
# clamped) and adds previous_in_category and next_in_category, the post's
|
||||
# neighbours by publish time within its first category by name.
|
||||
related_mode: category
|
||||
related_count: 3
|
||||
|
||||
@@ -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))
|
||||
|
||||
234
related_media_test.go
Normal file
234
related_media_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package journal_test
|
||||
|
||||
// Related posts and category neighbours on GET /posts/{slug} (D-82, P33-44):
|
||||
// the default answer is today's (same category, three, newest first, no new
|
||||
// keys); golem15.journal.related_mode scored ranks by shared tags (2 points
|
||||
// each) and shared categories (1 point each), ties by newest, counts 3 to 6
|
||||
// (golem15.journal.related_count, clamped), and adds previous_in_category
|
||||
// and next_in_category within the post's first category by name.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
)
|
||||
|
||||
// relatedFixture plants the target post and its candidates. Each candidate's
|
||||
// score against "target" is in its comment; the newer of two equal scores
|
||||
// ranks first.
|
||||
type relatedFixture struct {
|
||||
byName map[string]uint
|
||||
}
|
||||
|
||||
func plantRelated(t *testing.T, env *adminEnv) relatedFixture {
|
||||
t.Helper()
|
||||
db := env.gdb
|
||||
now := time.Now().UTC()
|
||||
cat := func(name, slug string) uint {
|
||||
c := models.Category{Name: name, Slug: slug}
|
||||
if err := db.Create(&c).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c.ID
|
||||
}
|
||||
tag := func(name string) uint {
|
||||
tg := models.Tag{Name: name, Slug: name}
|
||||
if err := db.Create(&tg).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tg.ID
|
||||
}
|
||||
// "Alpha" sorts before "Beta", so Alpha is the target's first category.
|
||||
alpha, beta, other := cat("Alpha", "alpha"), cat("Beta", "beta"), cat("Other", "other")
|
||||
t1, t2, t3 := tag("t1"), tag("t2"), tag("t3")
|
||||
f := relatedFixture{byName: map[string]uint{}}
|
||||
post := func(slug string, age time.Duration, published bool, future bool, cats []uint, tags []uint) {
|
||||
at := now.Add(-age)
|
||||
if future {
|
||||
at = now.Add(age)
|
||||
}
|
||||
p := models.Post{Title: "Post " + slug, Slug: slug, Content: "body " + slug, Published: published, PublishedAt: &at}
|
||||
if err := db.Create(&p).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range cats {
|
||||
if err := db.Create(&models.PostCategory{PostID: p.ID, CategoryID: c}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, tg := range tags {
|
||||
if err := db.Create(&models.PostTag{PostID: p.ID, TagID: tg}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f.byName[slug] = p.ID
|
||||
}
|
||||
hour := time.Hour
|
||||
post("target", 50*hour, true, false, []uint{alpha, beta}, []uint{t1, t2})
|
||||
post("a", 90*hour, true, false, []uint{alpha}, []uint{t1, t2}) // 5
|
||||
post("b", 80*hour, true, false, []uint{alpha, beta}, []uint{t1}) // 4 (older than c)
|
||||
post("c", 10*hour, true, false, nil, []uint{t1, t2}) // 4
|
||||
post("g", 70*hour, true, false, nil, []uint{t2}) // 2
|
||||
post("d", 20*hour, true, false, []uint{beta}, nil) // 1 (newer than h)
|
||||
post("h", 95*hour, true, false, []uint{alpha}, nil) // 1
|
||||
post("e", 5*hour, true, false, []uint{other}, []uint{t3}) // 0: never related
|
||||
post("draft", 1*hour, false, false, []uint{alpha, beta}, []uint{t1, t2}) // 6, unpublished
|
||||
post("later", 30*hour, true, true, []uint{alpha, beta}, []uint{t1, t2}) // 6, published in the future
|
||||
// In Alpha, by published_at: h (95 h ago), a (90), b (80), target (50);
|
||||
// the draft and the future post never count. Site-wide, target's nearest
|
||||
// newer published post is d (20 h ago) and its nearest older one g (70).
|
||||
return f
|
||||
}
|
||||
|
||||
type showAnswer struct {
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func showPost(t *testing.T, env *adminEnv, slug string) showAnswer {
|
||||
t.Helper()
|
||||
rec := env.call(t, http.MethodGet, "/_journal/api/v1/posts/"+slug, "", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("show %s = %d: %s", slug, rec.Code, rec.Body.String())
|
||||
}
|
||||
var out showAnswer
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func relatedSlugs(t *testing.T, a showAnswer) []string {
|
||||
t.Helper()
|
||||
var rows []struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := json.Unmarshal(a.Data["related_posts"], &rows); err != nil {
|
||||
t.Fatalf("related_posts: %v", err)
|
||||
}
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.Slug)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func siblingSlug(t *testing.T, a showAnswer, key string) string {
|
||||
t.Helper()
|
||||
raw, ok := a.Data[key]
|
||||
if !ok {
|
||||
t.Fatalf("answer lacks %s", key)
|
||||
}
|
||||
if string(raw) == "null" {
|
||||
return ""
|
||||
}
|
||||
var s struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
t.Fatalf("%s: %v", key, err)
|
||||
}
|
||||
if s.ID == 0 || s.Title == "" {
|
||||
t.Fatalf("%s lacks id or title: %s", key, raw)
|
||||
}
|
||||
return s.Slug
|
||||
}
|
||||
|
||||
func TestRelatedDefaultUnchanged(t *testing.T) {
|
||||
for name, env := range map[string]*adminEnv{
|
||||
"no config": newPostsEnv(t),
|
||||
"category mode": newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__RELATED_MODE=category", "SUMMER_GOLEM15__JOURNAL__RELATED_COUNT=6"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
plantRelated(t, env)
|
||||
a := showPost(t, env, "target")
|
||||
// Today's rule: posts sharing a category, newest first, three,
|
||||
// whatever their tags; drafts and future posts left out for a
|
||||
// visitor.
|
||||
if got, want := relatedSlugs(t, a), []string{"d", "b", "a"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("related_posts = %v, want %v", got, want)
|
||||
}
|
||||
for _, key := range []string{"previous_in_category", "next_in_category"} {
|
||||
if _, ok := a.Data[key]; ok {
|
||||
t.Fatalf("default answer has %s: %s", key, a.Data[key])
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"previous_post", "next_post", "related_posts", "content_h1_to_h2"} {
|
||||
if _, ok := a.Data[key]; !ok {
|
||||
t.Fatalf("default answer lacks %s", key)
|
||||
}
|
||||
}
|
||||
// The site-wide neighbours keep their meaning.
|
||||
if got := siblingSlug(t, a, "previous_post"); got != "d" {
|
||||
t.Fatalf("previous_post = %q, want d", got)
|
||||
}
|
||||
if got := siblingSlug(t, a, "next_post"); got != "g" {
|
||||
t.Fatalf("next_post = %q, want g", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelatedScored(t *testing.T) {
|
||||
env := newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__RELATED_MODE=scored", "SUMMER_GOLEM15__JOURNAL__RELATED_COUNT=5")
|
||||
plantRelated(t, env)
|
||||
a := showPost(t, env, "target")
|
||||
// 2 per shared tag, 1 per shared category, ties by newest; e scores 0,
|
||||
// the draft and the future post never appear for a visitor (T-33-70).
|
||||
if got, want := relatedSlugs(t, a), []string{"a", "c", "b", "g", "d"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("related_posts = %v, want %v", got, want)
|
||||
}
|
||||
// Neighbours in the first category (Alpha), with the same direction as
|
||||
// previous_post and next_post: previous is the next newer published
|
||||
// post, next the next older one.
|
||||
if got := siblingSlug(t, a, "previous_in_category"); got != "" {
|
||||
t.Fatalf("previous_in_category = %q, want none (no newer published post in Alpha)", got)
|
||||
}
|
||||
if got := siblingSlug(t, a, "next_in_category"); got != "b" {
|
||||
t.Fatalf("next_in_category = %q, want b", got)
|
||||
}
|
||||
// The site-wide neighbours are unchanged in scored mode.
|
||||
if got := siblingSlug(t, a, "previous_post"); got != "d" {
|
||||
t.Fatalf("previous_post = %q, want d", got)
|
||||
}
|
||||
b := showPost(t, env, "b")
|
||||
if got := siblingSlug(t, b, "previous_in_category"); got != "target" {
|
||||
t.Fatalf("b previous_in_category = %q, want target", got)
|
||||
}
|
||||
if got := siblingSlug(t, b, "next_in_category"); got != "a" {
|
||||
t.Fatalf("b next_in_category = %q, want a", got)
|
||||
}
|
||||
for _, r := range relatedSlugs(t, b) {
|
||||
if r == "draft" || r == "later" || r == "b" {
|
||||
t.Fatalf("b related_posts holds %q: %v", r, relatedSlugs(t, b))
|
||||
}
|
||||
}
|
||||
// A post without tags or categories in common with anything has none.
|
||||
if got := relatedSlugs(t, showPost(t, env, "e")); len(got) != 0 {
|
||||
t.Fatalf("e related_posts = %v, want none", got)
|
||||
}
|
||||
if got := siblingSlug(t, showPost(t, env, "c"), "next_in_category"); got != "" {
|
||||
t.Fatalf("c (no category) next_in_category = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelatedScoredCountClamped(t *testing.T) {
|
||||
for count, want := range map[string][]string{
|
||||
"10": {"a", "c", "b", "g", "d", "h"},
|
||||
"1": {"a", "c", "b"},
|
||||
"x": {"a", "c", "b"},
|
||||
} {
|
||||
t.Run(count, func(t *testing.T) {
|
||||
env := newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__RELATED_MODE=scored", "SUMMER_GOLEM15__JOURNAL__RELATED_COUNT="+count)
|
||||
plantRelated(t, env)
|
||||
if got := relatedSlugs(t, showPost(t, env, "target")); !slices.Equal(got, want) {
|
||||
t.Fatalf("related_count %s: related_posts = %v, want %v", count, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user