feat(help-center): expose public portal search
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -75,6 +76,46 @@ func (h *ArticleHandler) PublicList(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// PublicSearch returns public help-center search results for the portal search page.
|
||||
// GET /hc/:slug/:locale/search
|
||||
func (h *ArticleHandler) PublicSearch(c *gin.Context) {
|
||||
portal, ok := h.resolvePublicPortal(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
page, perPage, offset, ok := publicSearchPagination(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(c.Query("query"))
|
||||
if query == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"payload": []gin.H{}, "meta": gin.H{"articles_count": 0, "current_page": page}})
|
||||
return
|
||||
}
|
||||
|
||||
params := repository.ArticleSearchParams{
|
||||
PortalID: portal.ID,
|
||||
Query: query,
|
||||
Locale: c.Param("locale"),
|
||||
Status: string(model.ArticleStatusPublished),
|
||||
SortBy: c.Query("sort"),
|
||||
Offset: offset,
|
||||
Limit: perPage,
|
||||
}
|
||||
articles, count, err := h.svc.Search(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Public article search: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to search articles")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"payload": publicSearchArticlePayloads(articles, portal.Slug, query),
|
||||
"meta": gin.H{"articles_count": count, "current_page": page},
|
||||
})
|
||||
}
|
||||
|
||||
// NewArticleHandler creates a new ArticleHandler.
|
||||
func NewArticleHandler(svc *service.ArticleService, portalSvc ...*service.PortalService) *ArticleHandler {
|
||||
h := &ArticleHandler{svc: svc}
|
||||
@@ -556,6 +597,23 @@ func publicArticlePagination(c *gin.Context) (int, int, int, bool) {
|
||||
return page, perPage, offset, true
|
||||
}
|
||||
|
||||
func publicSearchPagination(c *gin.Context) (int, int, int, bool) {
|
||||
page := 1
|
||||
if raw := c.Query("page"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid page")
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
if parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
perPage := 10
|
||||
offset := (page - 1) * perPage
|
||||
return page, perPage, offset, true
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) articleSearchParams(c *gin.Context, portalID uint, offset, limit int) (repository.ArticleSearchParams, bool) {
|
||||
params := repository.ArticleSearchParams{
|
||||
PortalID: portalID,
|
||||
@@ -723,6 +781,79 @@ func publicArticlePayload(article *model.Article, portalSlug string) gin.H {
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicSearchArticlePayloads(articles []model.Article, portalSlug, query string) []gin.H {
|
||||
payload := make([]gin.H, 0, len(articles))
|
||||
for i := range articles {
|
||||
payload = append(payload, publicSearchArticlePayload(&articles[i], portalSlug, query))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicSearchArticlePayload(article *model.Article, portalSlug, query string) gin.H {
|
||||
if article == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
return gin.H{
|
||||
"id": article.ID,
|
||||
"category_id": article.CategoryID,
|
||||
"title": article.Title,
|
||||
"content": publicSearchArticleSnippet(article.Content, query),
|
||||
"link": "/hc/" + portalSlug + "/articles/" + article.Slug,
|
||||
}
|
||||
}
|
||||
|
||||
var publicSearchHTMLTagPattern = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
func publicSearchArticleSnippet(content, query string) string {
|
||||
plain := publicSearchPlainText(content)
|
||||
if plain == "" {
|
||||
return ""
|
||||
}
|
||||
lowerPlain := strings.ToLower(plain)
|
||||
lowerQuery := strings.ToLower(strings.TrimSpace(query))
|
||||
if lowerQuery != "" {
|
||||
if idx := strings.Index(lowerPlain, lowerQuery); idx >= 0 {
|
||||
start := idx - 110
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := idx + len(query) + 110
|
||||
if end > len(plain) {
|
||||
end = len(plain)
|
||||
}
|
||||
snippet := strings.TrimSpace(plain[start:end])
|
||||
if start > 0 {
|
||||
snippet = "..." + snippet
|
||||
}
|
||||
if end < len(plain) {
|
||||
snippet += "..."
|
||||
}
|
||||
return snippet
|
||||
}
|
||||
}
|
||||
return publicSearchTruncate(plain, 220)
|
||||
}
|
||||
|
||||
func publicSearchPlainText(content string) string {
|
||||
text := publicSearchHTMLTagPattern.ReplaceAllString(content, " ")
|
||||
replacements := []string{"#", "*", "_", "`", ">", "[", "]", "(", ")", "!"}
|
||||
for _, old := range replacements {
|
||||
text = strings.ReplaceAll(text, old, " ")
|
||||
}
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
func publicSearchTruncate(text string, limit int) string {
|
||||
if limit <= 0 || len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
cut := text[:limit]
|
||||
if idx := strings.LastIndex(cut, " "); idx > 0 {
|
||||
cut = cut[:idx]
|
||||
}
|
||||
return strings.TrimSpace(cut) + "..."
|
||||
}
|
||||
|
||||
func publicAssociatedArticlePayloads(articles []model.Article) []gin.H {
|
||||
payload := make([]gin.H, 0, len(articles))
|
||||
for i := range articles {
|
||||
|
||||
Reference in New Issue
Block a user