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 }