Files
go_blog/handlers/article.go
T
kevinandClaude Fable 5 2bdc368399 feat: implement tag management and search functionality
Features:
- Tag system with multi-language support (Chinese/English)
- Auto-create tags when associating with articles
- Tag filtering on homepage with visual indicators
- Full-text search across title, summary, and content
- Tag cloud sidebar showing article counts
- Search page with results display

Technical Implementation:
- Database models: Tag, ArticleTag (many-to-many)
- Tag count caching for performance
- Automatic tag slug generation
- Responsive design with mobile support
- I18n support for all new UI elements

Bug Fix:
- Fixed SQL column ambiguity error in tag filtering
- Added table prefix to ORDER BY clause in publishedArticleOrder

Routes:
- GET /search - Search results page
- GET /?tag=<slug> - Filter articles by tag
- GET /api/articles?tag=<slug> - API with tag filter support

Templates:
- Added tag input field to article create/edit form
- Added search box to homepage header
- Added tag cloud sidebar on homepage and search page
- Created new search results page template

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 20:59:32 +08:00

484 lines
14 KiB
Go

package handlers
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"go_blog/models"
"gorm.io/gorm"
)
// slugSepRe matches runs of non-word characters (anything that is not a
// letter or digit); these become single dashes.
var slugSepRe = regexp.MustCompile(`[^\p{L}\p{N}]+`)
// slugDashRe matches runs of dashes to collapse.
var slugDashRe = regexp.MustCompile(`-{2,}`)
// slugAsciiRe matches a slug made only of URL-safe ASCII letters, digits and
// dashes. Slugs containing other characters (e.g. CJK, or Unicode lowercase
// quirks like the Turkish dotless i) are not URL-stable and are discarded in
// favor of a "post-<id>" fallback.
var slugAsciiRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
// randomToken returns a 32-byte hex token used to own pending attachments on
// the article-create page until the article is saved.
func randomToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// Extremely unlikely; fall back to a time-based value.
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(b)
}
// generateSlug converts a title into a URL-friendly ASCII slug. Returns "" when
// the title yields no URL-safe ASCII characters (the caller must fall back,
// e.g. to "post-<id>"). Non-ASCII letters are intentionally dropped rather
// than kept, because raw CJK in a URL slug is not stable.
func generateSlug(title string) string {
s := strings.ToLower(strings.TrimSpace(title))
s = slugSepRe.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
s = slugDashRe.ReplaceAllString(s, "-")
if !slugAsciiRe.MatchString(s) {
return ""
}
return s
}
// fallbackSlug returns a non-empty slug when title-derived slugs are empty
// (e.g. a title of only punctuation/whitespace, or all-stripped). Uses the
// article ID when available, else a short token.
func fallbackSlug(id uint) string {
if id > 0 {
return fmt.Sprintf("post-%d", id)
}
return "post-" + randomToken()[:8]
}
// articleForm holds the parsed article form fields, shared by the create and
// edit handlers and their validation-error repopulation paths.
type articleForm struct {
Title string
Slug string
Summary string
Content string
Cover string
StatusStr string
IsTop bool
PublishedAt string // datetime-local format: "2006-01-02T15:04"
Tags string // comma-separated tag names
Action string // form action URL
TitleText string // page heading text (create vs edit)
ArticleID uint // existing article ID (edit page); 0 on create
SessionToken string // pending-attachment ownership token (create page)
}
// parseArticleForm reads and trims the article form fields from the request.
func parseArticleForm(c *gin.Context) articleForm {
return articleForm{
Title: strings.TrimSpace(c.PostForm("title")),
Slug: strings.TrimSpace(c.PostForm("slug")),
Summary: strings.TrimSpace(c.PostForm("summary")),
Content: strings.TrimSpace(c.PostForm("content")),
Cover: strings.TrimSpace(c.PostForm("cover")),
StatusStr: c.PostForm("status"),
IsTop: c.PostForm("is_top") == "1",
PublishedAt: strings.TrimSpace(c.PostForm("published_at")),
Tags: strings.TrimSpace(c.PostForm("tags")),
}
}
// applyFormToData writes the form field values into the template data map so
// the form is repopulated on render (initial load or validation error).
func applyFormToData(data gin.H, f articleForm) {
data["FormTitle"] = f.Title
data["FormSlug"] = f.Slug
data["FormSummary"] = f.Summary
data["FormContent"] = f.Content
data["FormCover"] = f.Cover
data["FormStatus"] = f.StatusStr
data["FormIsTop"] = f.IsTop
data["FormPublishedAt"] = f.PublishedAt
data["FormTags"] = f.Tags
data["FormAction"] = f.Action
data["FormTitleText"] = f.TitleText
data["FormArticleID"] = f.ArticleID
data["SessionToken"] = f.SessionToken
}
// renderArticleForm renders the shared article form template with the given
// form values and optional error message.
func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
data := DefaultData(c)
data["Title"] = f.TitleText
if errMsg != "" {
data["Error"] = errMsg
}
applyFormToData(data, f)
c.HTML(http.StatusOK, "article_create", data)
}
// sessionAuthorID extracts the logged-in user's ID from the session, defending
// against int/uint/int64/float64 storage. Returns ok=false if absent.
func sessionAuthorID(c *gin.Context) (uint, bool) {
session := sessions.Default(c)
userID := session.Get("user_id")
if userID == nil {
return 0, false
}
switch v := userID.(type) {
case uint:
return v, true
case int:
return uint(v), true
case int64:
return uint(v), true
case float64:
return uint(v), true
default:
return 0, false
}
}
// statusFromForm parses the status string ("0" draft, "1" published) and
// returns the model status constant, defaulting to draft.
func statusFromForm(statusStr string) int {
if statusStr == "1" {
return models.ArticlePublished
}
return models.ArticleDraft
}
// parsePublishedAt parses the datetime-local format ("2006-01-02T15:04") from
// the form into a time.Time pointer. Returns nil if the string is empty or invalid.
func parsePublishedAt(publishedAtStr string) *time.Time {
if publishedAtStr == "" {
return nil
}
// datetime-local format: "2006-01-02T15:04"
t, err := time.ParseInLocation("2006-01-02T15:04", publishedAtStr, time.Local)
if err != nil {
return nil
}
return &t
}
// formatPublishedAt formats a time.Time pointer into datetime-local format for the form.
// Returns empty string if the pointer is nil.
func formatPublishedAt(t *time.Time) string {
if t == nil {
return ""
}
return t.Local().Format("2006-01-02T15:04")
}
// parseTags splits comma-separated tag string and returns tag names.
func parseTags(tagStr string) []string {
if tagStr == "" {
return []string{}
}
parts := strings.Split(tagStr, ",")
var tags []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
tags = append(tags, trimmed)
}
}
return tags
}
// syncArticleTags associates tags with an article (find or create tags).
func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) error {
// Clear existing tags
if err := db.Model(article).Association("Tags").Clear(); err != nil {
return err
}
// If no tags, we're done
if len(tagNames) == 0 {
return nil
}
// Find or create each tag and associate with article
var tags []models.Tag
for _, name := range tagNames {
tag, err := models.FindOrCreateTag(db, name, name)
if err != nil {
return err
}
if tag != nil {
tags = append(tags, *tag)
}
}
// Associate tags with article
if len(tags) > 0 {
if err := db.Model(article).Association("Tags").Append(tags); err != nil {
return err
}
}
// Update tag counts
for _, tag := range tags {
models.UpdateTagCount(db, tag.ID)
}
return nil
}
// formatArticleTags converts article tags to comma-separated string for form.
func formatArticleTags(tags []models.Tag) string {
if len(tags) == 0 {
return ""
}
var names []string
for _, tag := range tags {
names = append(names, tag.NameZh)
}
return strings.Join(names, ", ")
}
// ArticleCreatePage renders the article creation form.
func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
renderArticleForm(c, db, articleForm{
Action: "/admin/articles/new",
TitleText: tr["article_create_title"],
StatusStr: "0",
SessionToken: randomToken(),
}, "")
}
}
// ArticleCreate handles the POST request to create a new article.
func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
f := parseArticleForm(c)
f.Action = "/admin/articles/new"
f.TitleText = tr["article_create_title"]
f.SessionToken = strings.TrimSpace(c.PostForm("session_token"))
// Validate required fields.
if f.Title == "" {
renderArticleForm(c, db, f, tr["article_title_required"])
return
}
if f.Content == "" {
renderArticleForm(c, db, f, tr["article_content_required"])
return
}
// Auto-generate slug if empty.
if f.Slug == "" {
f.Slug = generateSlug(f.Title)
// Title had no usable characters (e.g. only punctuation). Use a
// temporary token-based slug now; refine to post-<id> after insert.
if f.Slug == "" {
f.Slug = fallbackSlug(0)
}
}
status := statusFromForm(f.StatusStr)
authorID, ok := sessionAuthorID(c)
if !ok {
c.Redirect(http.StatusFound, "/login")
return
}
var publishedAt *time.Time
if f.PublishedAt != "" {
// User provided a custom published time
publishedAt = parsePublishedAt(f.PublishedAt)
} else if status == models.ArticlePublished {
// Auto-stamp with current time if publishing without custom time
now := time.Now()
publishedAt = &now
}
article := models.Article{
AuthorID: authorID,
Title: f.Title,
Slug: f.Slug,
Summary: f.Summary,
Content: f.Content,
Cover: f.Cover,
Status: status,
IsTop: f.IsTop,
PublishedAt: publishedAt,
}
if err := db.Create(&article).Error; err != nil {
renderArticleForm(c, db, f, tr["article_error"])
return
}
// Refine a token-based placeholder slug to the readable post-<id> form.
if strings.HasPrefix(f.Slug, "post-") && len(f.Slug) > 9 {
if newSlug := fallbackSlug(article.ID); newSlug != "" {
db.Model(&article).Update("slug", newSlug)
}
}
// Sync article tags
tagNames := parseTags(f.Tags)
if err := syncArticleTags(db, &article, tagNames); err != nil {
// Log error but don't fail the article creation
// The article is already created, tags are optional
}
// Bind any attachments uploaded during creation (plan A: pending rows
// owned by session_token, article_id=0).
if f.SessionToken != "" {
_ = BindPendingAttachments(db, f.SessionToken, article.ID)
}
// Success: redirect to dashboard.
c.Redirect(http.StatusFound, "/admin")
}
}
// ArticleListPage renders the admin article management list.
func ArticleListPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
var articles []models.Article
db.Order("created_at DESC").Find(&articles)
data := DefaultData(c)
data["Title"] = tr["article_list_title"]
data["Articles"] = articles
c.HTML(http.StatusOK, "article_list", data)
}
}
// ArticleEditPage renders the shared form prefilled with an existing article.
func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
id := c.Param("id")
var article models.Article
if err := db.First(&article, "id = ?", id).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/articles")
return
}
// Preload tags for the article
db.Model(&article).Association("Tags").Find(&article.Tags)
renderArticleForm(c, db, articleForm{
Title: article.Title,
Slug: article.Slug,
Summary: article.Summary,
Content: article.Content,
Cover: article.Cover,
StatusStr: strconv.Itoa(article.Status),
IsTop: article.IsTop,
PublishedAt: formatPublishedAt(article.PublishedAt),
Tags: formatArticleTags(article.Tags),
Action: "/admin/articles/" + id + "/edit",
TitleText: tr["article_edit_title"],
ArticleID: article.ID,
}, "")
}
}
// ArticleUpdate handles the POST request to update an existing article.
func ArticleUpdate(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
id := c.Param("id")
var article models.Article
if err := db.First(&article, "id = ?", id).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/articles")
return
}
f := parseArticleForm(c)
f.Action = "/admin/articles/" + id + "/edit"
f.TitleText = tr["article_edit_title"]
if f.Title == "" {
renderArticleForm(c, db, f, tr["article_title_required"])
return
}
if f.Content == "" {
renderArticleForm(c, db, f, tr["article_content_required"])
return
}
if f.Slug == "" {
f.Slug = generateSlug(f.Title)
if f.Slug == "" {
f.Slug = fallbackSlug(article.ID)
}
}
newStatus := statusFromForm(f.StatusStr)
// Handle published_at: use form value if provided, otherwise auto-stamp on first publish.
var publishedAt *time.Time
if f.PublishedAt != "" {
// User provided a custom published time
publishedAt = parsePublishedAt(f.PublishedAt)
} else {
// Auto-stamp the publish time the first time an article is published.
wasPublished := article.Status == models.ArticlePublished
publishedAt = article.PublishedAt
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
now := time.Now()
publishedAt = &now
}
}
updates := map[string]interface{}{
"Title": f.Title,
"Slug": f.Slug,
"Summary": f.Summary,
"Content": f.Content,
"Cover": f.Cover,
"Status": newStatus,
"IsTop": f.IsTop,
"PublishedAt": publishedAt,
}
if err := db.Model(&article).Updates(updates).Error; err != nil {
renderArticleForm(c, db, f, tr["article_error"])
return
}
// Sync article tags
tagNames := parseTags(f.Tags)
if err := syncArticleTags(db, &article, tagNames); err != nil {
// Log error but don't fail the article update
}
c.Redirect(http.StatusFound, "/admin/articles")
}
}
// ArticleDelete soft-deletes an article (GORM fills DeletedAt) and redirects
// back to the management list.
func ArticleDelete(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
db.Delete(&models.Article{}, "id = ?", id)
c.Redirect(http.StatusFound, "/admin/articles")
}
}