Some checks failed
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m22s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m21s
Build and Release / Lint (push) Successful in 5m38s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m59s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 8h5m19s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 7m10s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been cancelled
Build and Release / Build Binary (linux/arm64) (push) Has been cancelled
Adds keyword search and tag filtering to repository blog list with GetRepoTopTags for popular tags display. Implements user-level package privacy setting (KeepPackagesPrivate) to hide packages from profile page. Updates blog UI with search box, tag cloud, and clear filters button. Adds subscription CTA buttons and active subscription indicators.
459 lines
13 KiB
Go
459 lines
13 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package blog
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"code.gitcaddy.com/server/v3/models/db"
|
|
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
|
"code.gitcaddy.com/server/v3/models/unit"
|
|
user_model "code.gitcaddy.com/server/v3/models/user"
|
|
"code.gitcaddy.com/server/v3/modules/timeutil"
|
|
|
|
"xorm.io/xorm"
|
|
)
|
|
|
|
// BlogPostStatus represents the publication state of a blog post.
|
|
type BlogPostStatus int //revive:disable-line:exported
|
|
|
|
const (
|
|
BlogPostDraft BlogPostStatus = 0
|
|
BlogPostPublic BlogPostStatus = 1
|
|
BlogPostPublished BlogPostStatus = 2
|
|
)
|
|
|
|
// String returns a human-readable label for the blog post status.
|
|
func (s BlogPostStatus) String() string {
|
|
switch s {
|
|
case BlogPostDraft:
|
|
return "draft"
|
|
case BlogPostPublic:
|
|
return "public"
|
|
case BlogPostPublished:
|
|
return "published"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// BlogPost represents a blog article belonging to a repository.
|
|
type BlogPost struct { //revive:disable-line:exported
|
|
ID int64 `xorm:"pk autoincr"`
|
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
|
AuthorID int64 `xorm:"INDEX NOT NULL"`
|
|
Title string `xorm:"VARCHAR(255) NOT NULL"`
|
|
Subtitle string `xorm:"VARCHAR(500)"`
|
|
Content string `xorm:"LONGTEXT NOT NULL"`
|
|
RenderedContent string `xorm:"-"`
|
|
Tags string `xorm:"TEXT"`
|
|
Series string `xorm:"VARCHAR(255)"`
|
|
FeaturedImageID int64 `xorm:"DEFAULT 0"`
|
|
Status BlogPostStatus `xorm:"SMALLINT NOT NULL DEFAULT 0"`
|
|
AllowComments bool `xorm:"NOT NULL DEFAULT true"`
|
|
SubscriptionOnly bool `xorm:"NOT NULL DEFAULT false"`
|
|
PublishedUnix timeutil.TimeStamp `xorm:"INDEX"`
|
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
|
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
|
|
|
Author *user_model.User `xorm:"-"`
|
|
Repo *repo_model.Repository `xorm:"-"`
|
|
FeaturedImage *repo_model.Attachment `xorm:"-"`
|
|
}
|
|
|
|
func init() {
|
|
db.RegisterModel(new(BlogPost))
|
|
}
|
|
|
|
// LoadAuthor loads the author user for a blog post.
|
|
func (p *BlogPost) LoadAuthor(ctx context.Context) error {
|
|
if p.Author != nil {
|
|
return nil
|
|
}
|
|
u, err := user_model.GetUserByID(ctx, p.AuthorID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Author = u
|
|
return nil
|
|
}
|
|
|
|
// LoadRepo loads the repository for a blog post.
|
|
func (p *BlogPost) LoadRepo(ctx context.Context) error {
|
|
if p.Repo != nil {
|
|
return nil
|
|
}
|
|
r, err := repo_model.GetRepositoryByID(ctx, p.RepoID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Repo = r
|
|
return nil
|
|
}
|
|
|
|
// LoadFeaturedImage loads the featured image attachment.
|
|
func (p *BlogPost) LoadFeaturedImage(ctx context.Context) error {
|
|
if p.FeaturedImage != nil || p.FeaturedImageID == 0 {
|
|
return nil
|
|
}
|
|
a, err := repo_model.GetAttachmentByID(ctx, p.FeaturedImageID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.FeaturedImage = a
|
|
return nil
|
|
}
|
|
|
|
// BlogPostSearchOptions contains filters for querying blog posts.
|
|
type BlogPostSearchOptions struct { //revive:disable-line:exported
|
|
RepoID int64
|
|
AuthorID int64
|
|
Status BlogPostStatus
|
|
AnyPublicStatus bool // if true, matches Public OR Published
|
|
Keyword string
|
|
Tag string
|
|
Page int
|
|
PageSize int
|
|
}
|
|
|
|
// GetBlogPostByID returns a single blog post by ID.
|
|
func GetBlogPostByID(ctx context.Context, id int64) (*BlogPost, error) {
|
|
p := &BlogPost{}
|
|
has, err := db.GetEngine(ctx).ID(id).Get(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !has {
|
|
return nil, fmt.Errorf("blog post %d not found", id)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// GetBlogPostsByRepoID returns blog posts for a repo with filters and pagination.
|
|
func GetBlogPostsByRepoID(ctx context.Context, opts *BlogPostSearchOptions) ([]*BlogPost, int64, error) {
|
|
sess := db.GetEngine(ctx).Where("repo_id = ?", opts.RepoID)
|
|
|
|
if opts.AnyPublicStatus {
|
|
sess = sess.And("status >= ?", BlogPostPublic)
|
|
} else if opts.Status >= 0 {
|
|
sess = sess.And("status = ?", opts.Status)
|
|
}
|
|
|
|
if opts.Keyword != "" {
|
|
sess = sess.And("(LOWER(title) LIKE ? OR LOWER(subtitle) LIKE ?)",
|
|
"%"+strings.ToLower(opts.Keyword)+"%",
|
|
"%"+strings.ToLower(opts.Keyword)+"%")
|
|
}
|
|
|
|
if opts.Tag != "" {
|
|
// Match tag in comma-separated list
|
|
sess = sess.And("(tags LIKE ? OR tags LIKE ? OR tags LIKE ? OR tags = ?)",
|
|
opts.Tag+",%", "%,"+opts.Tag+",%", "%,"+opts.Tag, opts.Tag)
|
|
}
|
|
|
|
count, err := sess.Count(new(BlogPost))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Re-create session for find (xorm reuses conditions)
|
|
sess = db.GetEngine(ctx).Where("repo_id = ?", opts.RepoID)
|
|
if opts.AnyPublicStatus {
|
|
sess = sess.And("status >= ?", BlogPostPublic)
|
|
} else if opts.Status >= 0 {
|
|
sess = sess.And("status = ?", opts.Status)
|
|
}
|
|
|
|
if opts.Keyword != "" {
|
|
sess = sess.And("(LOWER(title) LIKE ? OR LOWER(subtitle) LIKE ?)",
|
|
"%"+strings.ToLower(opts.Keyword)+"%",
|
|
"%"+strings.ToLower(opts.Keyword)+"%")
|
|
}
|
|
|
|
if opts.Tag != "" {
|
|
sess = sess.And("(tags LIKE ? OR tags LIKE ? OR tags LIKE ? OR tags = ?)",
|
|
opts.Tag+",%", "%,"+opts.Tag+",%", "%,"+opts.Tag, opts.Tag)
|
|
}
|
|
|
|
pageSize := opts.PageSize
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
page := opts.Page
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
|
|
posts := make([]*BlogPost, 0, pageSize)
|
|
err = sess.OrderBy("created_unix DESC").
|
|
Limit(pageSize, (page-1)*pageSize).
|
|
Find(&posts)
|
|
return posts, count, err
|
|
}
|
|
|
|
// GetPublishedBlogPosts returns published blog posts across all repos.
|
|
func GetPublishedBlogPosts(ctx context.Context, page, pageSize int) ([]*BlogPost, int64, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
|
|
count, err := db.GetEngine(ctx).Where("status = ?", BlogPostPublished).Count(new(BlogPost))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
posts := make([]*BlogPost, 0, pageSize)
|
|
err = db.GetEngine(ctx).Where("status = ?", BlogPostPublished).
|
|
OrderBy("published_unix DESC").
|
|
Limit(pageSize, (page-1)*pageSize).
|
|
Find(&posts)
|
|
return posts, count, err
|
|
}
|
|
|
|
// ExploreBlogPostsOptions configures the explore blogs query.
|
|
type ExploreBlogPostsOptions struct {
|
|
Actor *user_model.User
|
|
Page int
|
|
PageSize int
|
|
SortType string // "newest" (default) or "popular"
|
|
Keyword string // search in title/subtitle
|
|
Tag string // filter by tag (exact match within comma-separated tags)
|
|
Series string // filter by series name (exact match)
|
|
}
|
|
|
|
// exploreBlogBaseSess creates a base session with blog status and repo access conditions.
|
|
func exploreBlogBaseSess(ctx context.Context, actor *user_model.User) *xorm.Session {
|
|
repoCond := repo_model.AccessibleRepositoryCondition(actor, unit.TypeInvalid)
|
|
return db.GetEngine(ctx).
|
|
Table("blog_post").
|
|
Join("INNER", "`repository`", "`blog_post`.repo_id = `repository`.id").
|
|
Where("blog_post.status >= ?", BlogPostPublic).
|
|
And(repoCond)
|
|
}
|
|
|
|
// GetExploreBlogPosts returns published/public blog posts visible to the actor,
|
|
// filtered by repository access permissions.
|
|
func GetExploreBlogPosts(ctx context.Context, opts *ExploreBlogPostsOptions) ([]*BlogPost, int64, error) {
|
|
if opts.PageSize <= 0 {
|
|
opts.PageSize = 20
|
|
}
|
|
if opts.Page <= 0 {
|
|
opts.Page = 1
|
|
}
|
|
|
|
countSess := exploreBlogBaseSess(ctx, opts.Actor)
|
|
findSess := exploreBlogBaseSess(ctx, opts.Actor)
|
|
|
|
if opts.Keyword != "" {
|
|
kw := "%" + opts.Keyword + "%"
|
|
countSess = countSess.And("(blog_post.title LIKE ? OR blog_post.subtitle LIKE ?)", kw, kw)
|
|
findSess = findSess.And("(blog_post.title LIKE ? OR blog_post.subtitle LIKE ?)", kw, kw)
|
|
}
|
|
|
|
if opts.Tag != "" {
|
|
// Match tag within comma-separated list: exact match, or starts with, ends with, or in middle
|
|
tagPattern := "%" + opts.Tag + "%"
|
|
countSess = countSess.And("blog_post.tags LIKE ?", tagPattern)
|
|
findSess = findSess.And("blog_post.tags LIKE ?", tagPattern)
|
|
}
|
|
|
|
if opts.Series != "" {
|
|
countSess = countSess.And("blog_post.series = ?", opts.Series)
|
|
findSess = findSess.And("blog_post.series = ?", opts.Series)
|
|
}
|
|
|
|
count, err := countSess.Count(new(BlogPost))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
switch opts.SortType {
|
|
case "popular":
|
|
findSess = findSess.OrderBy(
|
|
"(SELECT COUNT(*) FROM blog_reaction WHERE blog_reaction.blog_post_id = blog_post.id AND blog_reaction.is_like = ?) DESC, blog_post.published_unix DESC",
|
|
true,
|
|
)
|
|
default: // "newest"
|
|
findSess = findSess.OrderBy("blog_post.published_unix DESC")
|
|
}
|
|
|
|
posts := make([]*BlogPost, 0, opts.PageSize)
|
|
err = findSess.
|
|
Limit(opts.PageSize, (opts.Page-1)*opts.PageSize).
|
|
Find(&posts)
|
|
return posts, count, err
|
|
}
|
|
|
|
// TagCount represents a tag name and its usage count.
|
|
type TagCount struct {
|
|
Tag string
|
|
Count int
|
|
}
|
|
|
|
// GetExploreTopTags returns the top N tags across all accessible published blog posts.
|
|
func GetExploreTopTags(ctx context.Context, actor *user_model.User, limit int) ([]*TagCount, error) {
|
|
// Fetch all tags from accessible posts
|
|
type tagRow struct {
|
|
Tags string `xorm:"tags"`
|
|
}
|
|
var rows []tagRow
|
|
err := exploreBlogBaseSess(ctx, actor).
|
|
Cols("blog_post.tags").
|
|
Where("blog_post.tags != ''").
|
|
Find(&rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Aggregate tag counts
|
|
counts := make(map[string]int)
|
|
for _, r := range rows {
|
|
for t := range strings.SplitSeq(r.Tags, ",") {
|
|
t = strings.TrimSpace(t)
|
|
if t != "" {
|
|
counts[t]++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by count descending
|
|
result := make([]*TagCount, 0, len(counts))
|
|
for tag, c := range counts {
|
|
result = append(result, &TagCount{Tag: tag, Count: c})
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].Count != result[j].Count {
|
|
return result[i].Count > result[j].Count
|
|
}
|
|
return result[i].Tag < result[j].Tag
|
|
})
|
|
|
|
if limit > 0 && len(result) > limit {
|
|
result = result[:limit]
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetRepoTopTags returns the top N tags for a specific repo's published blog posts.
|
|
func GetRepoTopTags(ctx context.Context, repoID int64, limit int) ([]*TagCount, error) {
|
|
type tagRow struct {
|
|
Tags string `xorm:"tags"`
|
|
}
|
|
var rows []tagRow
|
|
err := db.GetEngine(ctx).Table("blog_post").
|
|
Cols("tags").
|
|
Where("repo_id = ? AND status >= ? AND tags != ''", repoID, BlogPostPublic).
|
|
Find(&rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Aggregate tag counts
|
|
counts := make(map[string]int)
|
|
for _, r := range rows {
|
|
for t := range strings.SplitSeq(r.Tags, ",") {
|
|
t = strings.TrimSpace(t)
|
|
if t != "" {
|
|
counts[t]++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by count descending
|
|
result := make([]*TagCount, 0, len(counts))
|
|
for tag, c := range counts {
|
|
result = append(result, &TagCount{Tag: tag, Count: c})
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].Count != result[j].Count {
|
|
return result[i].Count > result[j].Count
|
|
}
|
|
return result[i].Tag < result[j].Tag
|
|
})
|
|
|
|
if limit > 0 && len(result) > limit {
|
|
result = result[:limit]
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// CountPublicBlogPosts returns the total number of public/published blog posts across all repos.
|
|
func CountPublicBlogPosts(ctx context.Context) (int64, error) {
|
|
return db.GetEngine(ctx).Where("status >= ?", BlogPostPublic).Count(new(BlogPost))
|
|
}
|
|
|
|
// GetPublicBlogPostsPage returns a page of public/published blog posts for sitemap generation.
|
|
func GetPublicBlogPostsPage(ctx context.Context, page, pageSize int) ([]*BlogPost, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
posts := make([]*BlogPost, 0, pageSize)
|
|
err := db.GetEngine(ctx).Where("status >= ?", BlogPostPublic).
|
|
OrderBy("id ASC").
|
|
Limit(pageSize, (page-1)*pageSize).
|
|
Find(&posts)
|
|
return posts, err
|
|
}
|
|
|
|
// CountPublishedBlogsByRepoID returns the count of published/public blog posts for a repo.
|
|
func CountPublishedBlogsByRepoID(ctx context.Context, repoID int64) (int64, error) {
|
|
return db.GetEngine(ctx).Where("repo_id = ? AND status >= ?", repoID, BlogPostPublic).Count(new(BlogPost))
|
|
}
|
|
|
|
// CreateBlogPost inserts a new blog post.
|
|
func CreateBlogPost(ctx context.Context, p *BlogPost) error {
|
|
_, err := db.GetEngine(ctx).Insert(p)
|
|
return err
|
|
}
|
|
|
|
// UpdateBlogPost updates an existing blog post.
|
|
func UpdateBlogPost(ctx context.Context, p *BlogPost) error {
|
|
_, err := db.GetEngine(ctx).ID(p.ID).AllCols().Update(p)
|
|
return err
|
|
}
|
|
|
|
// DeleteBlogPost removes a blog post by ID.
|
|
func DeleteBlogPost(ctx context.Context, id int64) error {
|
|
_, err := db.GetEngine(ctx).ID(id).Delete(new(BlogPost))
|
|
return err
|
|
}
|
|
|
|
// CountPublishedBlogPostsByAuthorID returns the number of published/public blog posts by a user.
|
|
func CountPublishedBlogPostsByAuthorID(ctx context.Context, authorID int64) (int64, error) {
|
|
return db.GetEngine(ctx).Where("author_id = ? AND status >= ?", authorID, BlogPostPublic).Count(new(BlogPost))
|
|
}
|
|
|
|
// GetPublishedBlogPostsByAuthorID returns published/public blog posts by a user, ordered by published date descending.
|
|
func GetPublishedBlogPostsByAuthorID(ctx context.Context, authorID int64, page, pageSize int) ([]*BlogPost, int64, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
|
|
cond := "author_id = ? AND status >= ?"
|
|
|
|
count, err := db.GetEngine(ctx).Where(cond, authorID, BlogPostPublic).Count(new(BlogPost))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
posts := make([]*BlogPost, 0, pageSize)
|
|
err = db.GetEngine(ctx).Where(cond, authorID, BlogPostPublic).
|
|
OrderBy("published_unix DESC").
|
|
Limit(pageSize, (page-1)*pageSize).
|
|
Find(&posts)
|
|
return posts, count, err
|
|
}
|