851 lines
28 KiB
Go
851 lines
28 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/llm"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"golang.org/x/text/language"
|
|
"golang.org/x/text/language/display"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const TaskTypeCaptainArticleTranslate = "captain:article_translate"
|
|
|
|
// ArticleService implements business logic for Article CRUD.
|
|
type ArticleService struct {
|
|
repo *repository.ArticleRepo
|
|
searchIndexer SearchIndexer
|
|
worker *worker.WorkerPool
|
|
translator ArticleTranslationBackend
|
|
}
|
|
|
|
type ArticleTranslationBackend interface {
|
|
TranslateArticleText(ctx context.Context, account *model.Account, text string, targetLanguage string, kind string) (string, error)
|
|
}
|
|
|
|
type LLMArticleTranslationBackend struct {
|
|
provider llm.Provider
|
|
}
|
|
|
|
func NewLLMArticleTranslationBackend(provider llm.Provider) *LLMArticleTranslationBackend {
|
|
return &LLMArticleTranslationBackend{provider: provider}
|
|
}
|
|
|
|
func (b *LLMArticleTranslationBackend) TranslateArticleText(ctx context.Context, _ *model.Account, text string, targetLanguage string, kind string) (string, error) {
|
|
if b == nil || b.provider == nil {
|
|
return "", ErrArticleBulkTranslationWorkerUnavailable
|
|
}
|
|
kind = strings.ToLower(strings.TrimSpace(kind))
|
|
if kind != "title" && kind != "content" {
|
|
return "", fmt.Errorf("invalid article translation type: %s", kind)
|
|
}
|
|
resp, err := b.provider.ChatCompletion(ctx, llm.ChatRequest{
|
|
Messages: []llm.ChatMessage{
|
|
{Role: "system", Content: articleTranslationSystemPrompt(kind, languageNameForLocale(targetLanguage))},
|
|
{Role: "user", Content: text},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("article translation llm: %w", err)
|
|
}
|
|
if resp == nil || len(resp.Choices) == 0 {
|
|
return "", nil
|
|
}
|
|
return strings.TrimSpace(resp.Choices[0].Message.Content), nil
|
|
}
|
|
|
|
func NewArticleService(repo *repository.ArticleRepo) *ArticleService {
|
|
return &ArticleService{repo: repo}
|
|
}
|
|
|
|
func (s *ArticleService) SetSearchIndexer(indexer SearchIndexer) {
|
|
s.searchIndexer = indexer
|
|
}
|
|
|
|
func (s *ArticleService) SetWorkerPool(wp *worker.WorkerPool) {
|
|
s.worker = wp
|
|
RegisterArticleTranslationJobs(wp, s)
|
|
}
|
|
|
|
func (s *ArticleService) SetArticleTranslationBackend(backend ArticleTranslationBackend) {
|
|
s.translator = backend
|
|
}
|
|
|
|
func (s *ArticleService) indexArticle(ctx context.Context, article *model.Article) {
|
|
if s.searchIndexer != nil {
|
|
logSearchIndexError("article", article.ID, s.searchIndexer.IndexArticle(ctx, article))
|
|
}
|
|
}
|
|
|
|
func (s *ArticleService) deleteArticleIndex(ctx context.Context, accountID uint, id uint) {
|
|
if s.searchIndexer != nil {
|
|
logSearchIndexError("article", id, s.searchIndexer.DeleteArticle(ctx, accountID, id))
|
|
}
|
|
}
|
|
|
|
// CreateArticleRequest is the DTO for creating an article.
|
|
type CreateArticleRequest struct {
|
|
Title string `json:"title" validate:"required,min=2"`
|
|
Slug string `json:"slug"`
|
|
Description string `json:"description"`
|
|
Content string `json:"content"`
|
|
Status model.ArticleStatus `json:"status"`
|
|
FolderID *uint `json:"folder_id"`
|
|
CategoryID *uint `json:"category_id"`
|
|
AuthorID *uint `json:"author_id"`
|
|
Position int `json:"position"`
|
|
Locale string `json:"locale"`
|
|
AssociatedArticleID *uint `json:"associated_article_id"`
|
|
Meta json.RawMessage `json:"meta"`
|
|
CustomAttributes json.RawMessage `json:"custom_attributes"`
|
|
}
|
|
|
|
// UpdateArticleRequest is the DTO for updating an article.
|
|
type UpdateArticleRequest struct {
|
|
Title *string `json:"title"`
|
|
Slug *string `json:"slug"`
|
|
Description *string `json:"description"`
|
|
Content *string `json:"content"`
|
|
Status *model.ArticleStatus `json:"status"`
|
|
FolderID *uint `json:"folder_id"`
|
|
CategoryID *uint `json:"category_id"`
|
|
AuthorID *uint `json:"author_id"`
|
|
Position *int `json:"position"`
|
|
Locale *string `json:"locale"`
|
|
AssociatedArticleID *uint `json:"associated_article_id"`
|
|
Meta *json.RawMessage `json:"meta"`
|
|
CustomAttributes *json.RawMessage `json:"custom_attributes"`
|
|
}
|
|
|
|
func (s *ArticleService) Create(ctx context.Context, portalID uint, authorID uint, req *CreateArticleRequest) (*model.Article, error) {
|
|
return s.CreateWithAccount(ctx, 0, portalID, authorID, req)
|
|
}
|
|
|
|
func (s *ArticleService) CreateWithAccount(ctx context.Context, accountID uint, portalID uint, authorID uint, req *CreateArticleRequest) (*model.Article, error) {
|
|
// Default status is draft if not specified
|
|
status := string(req.Status)
|
|
if status == "" {
|
|
status = string(model.ArticleStatusDraft)
|
|
}
|
|
if req.AuthorID != nil {
|
|
authorID = *req.AuthorID
|
|
}
|
|
slug := req.Slug
|
|
if slug == "" {
|
|
slug = articleSlug(req.Title)
|
|
}
|
|
|
|
article := &model.Article{
|
|
AccountID: accountID,
|
|
PortalID: portalID,
|
|
AuthorID: &authorID,
|
|
Title: req.Title,
|
|
Slug: slug,
|
|
Description: req.Description,
|
|
Content: req.Content,
|
|
Status: status,
|
|
FolderID: req.FolderID,
|
|
CategoryID: req.CategoryID,
|
|
Position: req.Position,
|
|
Locale: req.Locale,
|
|
AssociatedArticleID: req.AssociatedArticleID,
|
|
Meta: req.Meta,
|
|
CustomAttributes: req.CustomAttributes,
|
|
}
|
|
|
|
if err := s.repo.Create(ctx, article); err != nil {
|
|
return nil, fmt.Errorf("create article: %w", err)
|
|
}
|
|
|
|
// If associated_article_id is provided, link this article to the root article chain
|
|
if req.AssociatedArticleID != nil {
|
|
applogger.L().Debugf("Article %d linked to root article %d", article.ID, *req.AssociatedArticleID)
|
|
}
|
|
s.indexArticle(ctx, article)
|
|
|
|
return s.repo.GetByPortalAndID(ctx, portalID, article.ID)
|
|
}
|
|
|
|
func (s *ArticleService) GetByID(ctx context.Context, id uint) (*model.Article, error) {
|
|
article, err := s.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get article: %w", err)
|
|
}
|
|
return article, nil
|
|
}
|
|
|
|
func (s *ArticleService) GetByPortalAndID(ctx context.Context, portalID, id uint) (*model.Article, error) {
|
|
article, err := s.repo.GetByPortalAndID(ctx, portalID, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get article: %w", err)
|
|
}
|
|
return article, nil
|
|
}
|
|
|
|
func (s *ArticleService) Update(ctx context.Context, id uint, req *UpdateArticleRequest) (*model.Article, error) {
|
|
article, err := s.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find article: %w", err)
|
|
}
|
|
return s.UpdateExisting(ctx, article, req)
|
|
}
|
|
|
|
func (s *ArticleService) UpdateScoped(ctx context.Context, portalID, id uint, req *UpdateArticleRequest) (*model.Article, error) {
|
|
article, err := s.repo.GetByPortalAndID(ctx, portalID, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find article: %w", err)
|
|
}
|
|
return s.UpdateExisting(ctx, article, req)
|
|
}
|
|
|
|
func (s *ArticleService) UpdateExisting(ctx context.Context, article *model.Article, req *UpdateArticleRequest) (*model.Article, error) {
|
|
if article == nil {
|
|
return nil, fmt.Errorf("find article: record not found")
|
|
}
|
|
// Update fields — only non-zero/non-nil values
|
|
if req.Title != nil {
|
|
article.Title = *req.Title
|
|
}
|
|
if req.Slug != nil {
|
|
article.Slug = *req.Slug
|
|
}
|
|
if req.Description != nil {
|
|
article.Description = *req.Description
|
|
}
|
|
if req.Content != nil {
|
|
article.Content = *req.Content
|
|
}
|
|
if req.Status != nil {
|
|
article.Status = string(*req.Status)
|
|
}
|
|
if req.FolderID != nil {
|
|
article.FolderID = req.FolderID
|
|
}
|
|
if req.CategoryID != nil {
|
|
article.CategoryID = req.CategoryID
|
|
}
|
|
if req.AuthorID != nil {
|
|
article.AuthorID = req.AuthorID
|
|
}
|
|
if req.Position != nil {
|
|
article.Position = *req.Position
|
|
}
|
|
if req.Locale != nil {
|
|
article.Locale = *req.Locale
|
|
}
|
|
if req.AssociatedArticleID != nil {
|
|
article.AssociatedArticleID = req.AssociatedArticleID
|
|
}
|
|
if req.Meta != nil {
|
|
article.Meta = *req.Meta
|
|
}
|
|
if req.CustomAttributes != nil {
|
|
article.CustomAttributes = *req.CustomAttributes
|
|
}
|
|
|
|
if err := s.repo.Update(ctx, article); err != nil {
|
|
return nil, fmt.Errorf("update article: %w", err)
|
|
}
|
|
s.indexArticle(ctx, article)
|
|
return s.repo.GetByPortalAndID(ctx, article.PortalID, article.ID)
|
|
}
|
|
|
|
func (s *ArticleService) Delete(ctx context.Context, id uint) error {
|
|
// Check existence first — GORM Delete() returns nil even for non-existent IDs
|
|
article, err := s.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("find article for delete: %w", err)
|
|
}
|
|
if err := s.repo.Delete(ctx, id); err != nil {
|
|
return fmt.Errorf("delete article: %w", err)
|
|
}
|
|
s.deleteArticleIndex(ctx, article.AccountID, id)
|
|
return nil
|
|
}
|
|
|
|
func (s *ArticleService) DeleteScoped(ctx context.Context, portalID, id uint) error {
|
|
article, err := s.repo.GetByPortalAndID(ctx, portalID, id)
|
|
if err != nil {
|
|
return fmt.Errorf("find article for delete: %w", err)
|
|
}
|
|
if err := s.repo.Delete(ctx, id); err != nil {
|
|
return fmt.Errorf("delete article: %w", err)
|
|
}
|
|
s.deleteArticleIndex(ctx, article.AccountID, id)
|
|
return nil
|
|
}
|
|
|
|
// ListByPortalID returns articles for a portal with pagination.
|
|
func (s *ArticleService) ListByPortalID(ctx context.Context, portalID uint, page, perPage int) ([]model.Article, int64, error) {
|
|
offset := (page - 1) * perPage
|
|
articles, count, err := s.repo.FindByPortalID(ctx, portalID, offset, perPage)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("list articles: %w", err)
|
|
}
|
|
return articles, count, nil
|
|
}
|
|
|
|
// ListByCategoryID returns articles for a category with pagination.
|
|
func (s *ArticleService) ListByCategoryID(ctx context.Context, categoryID uint, page, perPage int) ([]model.Article, int64, error) {
|
|
offset := (page - 1) * perPage
|
|
articles, count, err := s.repo.FindByCategoryID(ctx, categoryID, offset, perPage)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("list articles by category: %w", err)
|
|
}
|
|
return articles, count, nil
|
|
}
|
|
|
|
// ListByStatus returns articles by status for a portal.
|
|
func (s *ArticleService) ListByStatus(ctx context.Context, portalID uint, status string, page, perPage int) ([]model.Article, int64, error) {
|
|
offset := (page - 1) * perPage
|
|
articles, count, err := s.repo.FindByStatus(ctx, portalID, model.ArticleStatus(status), offset, perPage)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("list articles by status: %w", err)
|
|
}
|
|
return articles, count, nil
|
|
}
|
|
|
|
// Search returns filtered + searched articles with pagination.
|
|
// Implements Article.search(params) from M9 spec.
|
|
func (s *ArticleService) Search(ctx context.Context, params repository.ArticleSearchParams) ([]model.Article, int64, error) {
|
|
articles, count, err := s.repo.Search(ctx, params)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("search articles: %w", err)
|
|
}
|
|
return articles, count, nil
|
|
}
|
|
|
|
func (s *ArticleService) Count(ctx context.Context, params repository.ArticleSearchParams) (int64, error) {
|
|
count, err := s.repo.CountSearch(ctx, params)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("count articles: %w", err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// StatusCounts returns article counts by status for a portal.
|
|
func (s *ArticleService) StatusCounts(ctx context.Context, portalID uint) (map[string]int64, error) {
|
|
counts, err := s.repo.StatusCounts(ctx, portalID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("status counts: %w", err)
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
type ArticleListMeta struct {
|
|
AllArticlesCount int64
|
|
ArchivedArticlesCount int64
|
|
ArticlesCount int64
|
|
DraftArticlesCount int64
|
|
MineArticlesCount int64
|
|
PublishedCount int64
|
|
}
|
|
|
|
func (s *ArticleService) ListMeta(ctx context.Context, params repository.ArticleSearchParams, currentUserID uint) (*ArticleListMeta, error) {
|
|
allCount, err := s.repo.CountSearch(ctx, repository.ArticleSearchParams{PortalID: params.PortalID})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("count all articles: %w", err)
|
|
}
|
|
baseParams := params
|
|
baseParams.Status = ""
|
|
baseParams.AuthorID = nil
|
|
articlesCount, err := s.repo.CountSearch(ctx, baseParams)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("count articles: %w", err)
|
|
}
|
|
mineParams := baseParams
|
|
if currentUserID != 0 {
|
|
mineParams.AuthorID = ¤tUserID
|
|
}
|
|
mineCount := int64(0)
|
|
if currentUserID != 0 {
|
|
mineCount, err = s.repo.CountSearch(ctx, mineParams)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("count mine articles: %w", err)
|
|
}
|
|
}
|
|
publishedParams := baseParams
|
|
publishedParams.Status = string(model.ArticleStatusPublished)
|
|
publishedCount, err := s.repo.CountSearch(ctx, publishedParams)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("count published articles: %w", err)
|
|
}
|
|
draftParams := baseParams
|
|
draftParams.Status = string(model.ArticleStatusDraft)
|
|
draftCount, err := s.repo.CountSearch(ctx, draftParams)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("count draft articles: %w", err)
|
|
}
|
|
archivedParams := baseParams
|
|
archivedParams.Status = string(model.ArticleStatusArchived)
|
|
archivedCount, err := s.repo.CountSearch(ctx, archivedParams)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("count archived articles: %w", err)
|
|
}
|
|
return &ArticleListMeta{
|
|
AllArticlesCount: allCount,
|
|
ArchivedArticlesCount: archivedCount,
|
|
ArticlesCount: articlesCount,
|
|
DraftArticlesCount: draftCount,
|
|
MineArticlesCount: mineCount,
|
|
PublishedCount: publishedCount,
|
|
}, nil
|
|
}
|
|
|
|
// Reorder batch-updates article positions.
|
|
func (s *ArticleService) Reorder(ctx context.Context, positions map[uint]int) error {
|
|
if err := s.repo.UpdatePositions(ctx, positions); err != nil {
|
|
return fmt.Errorf("reorder articles: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ArticleService) ReorderScoped(ctx context.Context, portalID uint, positions map[uint]int) error {
|
|
if err := s.repo.UpdatePositionsForPortal(ctx, portalID, positions); err != nil {
|
|
return fmt.Errorf("reorder articles: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BulkUpdateStatus updates status for multiple articles.
|
|
func (s *ArticleService) BulkUpdateStatus(ctx context.Context, ids []uint, status string) error {
|
|
// Validate status
|
|
validStatuses := map[model.ArticleStatus]bool{
|
|
model.ArticleStatusDraft: true,
|
|
model.ArticleStatusPublished: true,
|
|
model.ArticleStatusArchived: true,
|
|
}
|
|
if !validStatuses[model.ArticleStatus(status)] {
|
|
return fmt.Errorf("invalid status: %s", status)
|
|
}
|
|
if err := s.repo.BulkUpdateStatus(ctx, ids, status); err != nil {
|
|
return fmt.Errorf("bulk update status: %w", err)
|
|
}
|
|
for _, id := range ids {
|
|
article, err := s.repo.GetByID(ctx, id)
|
|
if err == nil {
|
|
s.indexArticle(ctx, article)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ArticleService) BulkUpdateStatusScoped(ctx context.Context, portalID uint, ids []uint, status string) error {
|
|
count, err := s.repo.CountByPortalAndIDs(ctx, portalID, ids)
|
|
if err != nil {
|
|
return fmt.Errorf("count articles: %w", err)
|
|
}
|
|
if count == 0 {
|
|
return ErrArticleBulkNoArticles
|
|
}
|
|
if err := validateArticleBulkStatus(status); err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.BulkUpdateStatusForPortal(ctx, portalID, ids, status); err != nil {
|
|
return fmt.Errorf("bulk update status: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ArticleService) BulkUpdateCategoryScoped(ctx context.Context, portalID uint, ids []uint, categoryID uint) error {
|
|
count, err := s.repo.CountByPortalAndIDs(ctx, portalID, ids)
|
|
if err != nil {
|
|
return fmt.Errorf("count articles: %w", err)
|
|
}
|
|
if count == 0 {
|
|
return ErrArticleBulkNoArticles
|
|
}
|
|
exists, err := s.repo.CategoryExistsForPortal(ctx, portalID, categoryID)
|
|
if err != nil {
|
|
return fmt.Errorf("find category: %w", err)
|
|
}
|
|
if !exists {
|
|
return ErrArticleBulkCategoryNotFound
|
|
}
|
|
if err := s.repo.BulkUpdateCategoryForPortal(ctx, portalID, ids, categoryID); err != nil {
|
|
return fmt.Errorf("bulk update category: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BulkDelete deletes multiple articles.
|
|
func (s *ArticleService) BulkDelete(ctx context.Context, ids []uint) error {
|
|
articles := make([]*model.Article, 0, len(ids))
|
|
for _, id := range ids {
|
|
article, err := s.repo.GetByID(ctx, id)
|
|
if err == nil {
|
|
articles = append(articles, article)
|
|
}
|
|
}
|
|
if err := s.repo.BulkDelete(ctx, ids); err != nil {
|
|
return fmt.Errorf("bulk delete: %w", err)
|
|
}
|
|
for _, article := range articles {
|
|
s.deleteArticleIndex(ctx, article.AccountID, article.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ArticleService) BulkDeleteScoped(ctx context.Context, portalID uint, ids []uint) error {
|
|
count, err := s.repo.CountByPortalAndIDs(ctx, portalID, ids)
|
|
if err != nil {
|
|
return fmt.Errorf("count articles: %w", err)
|
|
}
|
|
if count == 0 {
|
|
return ErrArticleBulkNoArticles
|
|
}
|
|
if err := s.repo.BulkDeleteForPortal(ctx, portalID, ids); err != nil {
|
|
return fmt.Errorf("bulk delete: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IncrementViews increments the view count for an article by 1.
|
|
func (s *ArticleService) IncrementViews(ctx context.Context, id uint) error {
|
|
if err := s.repo.IncrementViews(ctx, id); err != nil {
|
|
return fmt.Errorf("increment views: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BulkActionsRequest is the DTO for performing bulk operations on articles.
|
|
// Action can be "publish", "archive", or "delete".
|
|
type BulkActionsRequest struct {
|
|
Action string `json:"action" validate:"required,oneof=publish archive delete"`
|
|
IDs []uint `json:"ids" validate:"required,min=1"`
|
|
}
|
|
|
|
type BulkTranslateRequest struct {
|
|
IDs []uint `json:"ids"`
|
|
Locale string `json:"locale"`
|
|
CategoryID *uint `json:"category_id"`
|
|
Force bool `json:"force"`
|
|
}
|
|
|
|
type ArticleTranslationDuplicate struct {
|
|
ID uint `json:"id"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
type ArticleTranslationConflictError struct {
|
|
Duplicates []ArticleTranslationDuplicate
|
|
}
|
|
|
|
func (e *ArticleTranslationConflictError) Error() string { return "article translations already exist" }
|
|
|
|
func (s *ArticleService) BulkTranslate(ctx context.Context, accountID uint, portal *model.Portal, userID uint, req BulkTranslateRequest) error {
|
|
if portal == nil {
|
|
return fmt.Errorf("portal not found")
|
|
}
|
|
var account model.Account
|
|
if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
|
|
return fmt.Errorf("account not found: %w", err)
|
|
}
|
|
if !featureFlagStringEnabled(account.FeatureFlags, "captain_tasks") {
|
|
return ErrArticleBulkCaptainNotAvailable
|
|
}
|
|
if !portalAllowsLocale(portal, req.Locale) {
|
|
return ErrArticleBulkLocaleNotAvailable
|
|
}
|
|
if req.CategoryID != nil {
|
|
exists, err := s.repo.CategoryExistsForPortalAndLocale(ctx, portal.ID, *req.CategoryID, req.Locale)
|
|
if err != nil {
|
|
return fmt.Errorf("find category: %w", err)
|
|
}
|
|
if !exists {
|
|
return ErrArticleBulkCategoryNotFound
|
|
}
|
|
}
|
|
articles, err := s.repo.FindByPortalAndIDs(ctx, portal.ID, req.IDs)
|
|
if err != nil {
|
|
return fmt.Errorf("find articles: %w", err)
|
|
}
|
|
if len(articles) == 0 {
|
|
return ErrArticleBulkNoArticles
|
|
}
|
|
rootIDs := articleRootIDs(articles)
|
|
duplicates, err := s.repo.FindTranslationsByRootIDsAndLocale(ctx, portal.ID, rootIDs, req.Locale)
|
|
if err != nil {
|
|
return fmt.Errorf("find translations: %w", err)
|
|
}
|
|
if len(duplicates) > 0 && !req.Force {
|
|
return &ArticleTranslationConflictError{Duplicates: articleTranslationDuplicates(duplicates)}
|
|
}
|
|
if s.worker == nil {
|
|
return ErrArticleBulkTranslationWorkerUnavailable
|
|
}
|
|
for _, article := range articles {
|
|
payload := captainArticleTranslateJob{AccountID: accountID, ArticleID: article.ID, Locale: req.Locale, UserID: userID}
|
|
if req.CategoryID != nil {
|
|
payload.CategoryID = *req.CategoryID
|
|
}
|
|
if _, err := s.worker.Enqueue(ctx, TaskTypeCaptainArticleTranslate, payload,
|
|
worker.WithQueue("low"),
|
|
worker.WithMaxAttempts(3),
|
|
worker.WithIdempotencyKey(fmt.Sprintf("captain:article_translate:%d:%d:%s", accountID, article.ID, req.Locale)),
|
|
); err != nil {
|
|
return fmt.Errorf("enqueue article translation: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BulkActions dispatches a bulk operation (publish/archive/delete) on the given article IDs.
|
|
// Reference: Chatwoot articles_controller#bulk_actions
|
|
func (s *ArticleService) BulkActions(ctx context.Context, req *BulkActionsRequest) error {
|
|
switch req.Action {
|
|
case "publish":
|
|
return s.BulkUpdateStatus(ctx, req.IDs, string(model.ArticleStatusPublished))
|
|
case "archive":
|
|
return s.BulkUpdateStatus(ctx, req.IDs, string(model.ArticleStatusArchived))
|
|
case "delete":
|
|
return s.BulkDelete(ctx, req.IDs)
|
|
default:
|
|
return fmt.Errorf("unsupported action: %s — must be publish, archive, or delete", req.Action)
|
|
}
|
|
}
|
|
|
|
var (
|
|
ErrArticleBulkNoArticles = fmt.Errorf("no articles found")
|
|
ErrArticleBulkInvalidStatus = fmt.Errorf("invalid status")
|
|
ErrArticleBulkCategoryNotFound = fmt.Errorf("category not found")
|
|
ErrArticleBulkCaptainNotAvailable = fmt.Errorf("captain not available")
|
|
ErrArticleBulkLocaleNotAvailable = fmt.Errorf("locale not available")
|
|
ErrArticleBulkTranslationWorkerUnavailable = fmt.Errorf("article translation worker unavailable")
|
|
)
|
|
|
|
type captainArticleTranslateJob struct {
|
|
AccountID uint `json:"account_id"`
|
|
ArticleID uint `json:"article_id"`
|
|
Locale string `json:"locale"`
|
|
CategoryID uint `json:"category_id,omitempty"`
|
|
UserID uint `json:"user_id"`
|
|
}
|
|
|
|
func RegisterArticleTranslationJobs(wp *worker.WorkerPool, svc *ArticleService) {
|
|
if wp == nil || svc == nil {
|
|
return
|
|
}
|
|
wp.Register(TaskTypeCaptainArticleTranslate, svc.performArticleTranslateJob)
|
|
}
|
|
|
|
func (s *ArticleService) performArticleTranslateJob(ctx context.Context, job *model.BackgroundJob) error {
|
|
var payload captainArticleTranslateJob
|
|
if len(job.Payload) > 0 {
|
|
if err := json.Unmarshal(job.Payload, &payload); err != nil {
|
|
return fmt.Errorf("unmarshal article translate job: %w", err)
|
|
}
|
|
}
|
|
if payload.AccountID == 0 || payload.ArticleID == 0 || payload.Locale == "" {
|
|
return fmt.Errorf("invalid article translate job payload: %#v", payload)
|
|
}
|
|
if s.translator == nil {
|
|
return ErrArticleBulkTranslationWorkerUnavailable
|
|
}
|
|
var account model.Account
|
|
if err := s.repo.DB().WithContext(ctx).First(&account, payload.AccountID).Error; err != nil {
|
|
return fmt.Errorf("account not found: %w", err)
|
|
}
|
|
source, err := s.repo.GetByAccountAndID(ctx, payload.AccountID, payload.ArticleID)
|
|
if err != nil {
|
|
return fmt.Errorf("source article not found: %w", err)
|
|
}
|
|
targetLanguage := languageNameForLocale(payload.Locale)
|
|
title, err := s.translator.TranslateArticleText(ctx, &account, source.Title, targetLanguage, "title")
|
|
if err != nil {
|
|
return fmt.Errorf("translate article title: %w", err)
|
|
}
|
|
content := source.Content
|
|
if strings.TrimSpace(source.Content) != "" {
|
|
content, err = s.translator.TranslateArticleText(ctx, &account, source.Content, targetLanguage, "content")
|
|
if err != nil {
|
|
return fmt.Errorf("translate article content: %w", err)
|
|
}
|
|
}
|
|
rootID := articleRootID(*source)
|
|
existing, err := s.repo.FindTranslationByRootAndLocale(ctx, source.PortalID, rootID, payload.Locale)
|
|
if err == nil && existing != nil {
|
|
existing.Title = title
|
|
existing.Content = content
|
|
existing.Description = source.Description
|
|
if err := s.repo.Update(ctx, existing); err != nil {
|
|
return fmt.Errorf("update article translation: %w", err)
|
|
}
|
|
s.indexArticle(ctx, existing)
|
|
return nil
|
|
}
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fmt.Errorf("find article translation: %w", err)
|
|
}
|
|
var categoryID *uint
|
|
if payload.CategoryID != 0 {
|
|
categoryID = &payload.CategoryID
|
|
}
|
|
translated := &model.Article{
|
|
AccountID: source.AccountID,
|
|
PortalID: source.PortalID,
|
|
CategoryID: categoryID,
|
|
AuthorID: &payload.UserID,
|
|
Title: title,
|
|
Slug: articleSlug(fmt.Sprintf("%s-%s-%d", title, payload.Locale, source.ID)),
|
|
Description: source.Description,
|
|
Content: content,
|
|
Status: string(model.ArticleStatusDraft),
|
|
Locale: payload.Locale,
|
|
AssociatedArticleID: &rootID,
|
|
}
|
|
if err := s.repo.Create(ctx, translated); err != nil {
|
|
return fmt.Errorf("create article translation: %w", err)
|
|
}
|
|
s.indexArticle(ctx, translated)
|
|
return nil
|
|
}
|
|
|
|
func articleRootID(article model.Article) uint {
|
|
if article.AssociatedArticleID != nil && *article.AssociatedArticleID != 0 {
|
|
return *article.AssociatedArticleID
|
|
}
|
|
return article.ID
|
|
}
|
|
|
|
func articleRootIDs(articles []model.Article) []uint {
|
|
seen := map[uint]bool{}
|
|
ids := make([]uint, 0, len(articles))
|
|
for _, article := range articles {
|
|
id := articleRootID(article)
|
|
if !seen[id] {
|
|
seen[id] = true
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func articleTranslationDuplicates(articles []model.Article) []ArticleTranslationDuplicate {
|
|
duplicates := make([]ArticleTranslationDuplicate, 0, len(articles))
|
|
for _, article := range articles {
|
|
duplicates = append(duplicates, ArticleTranslationDuplicate{ID: article.ID, Title: article.Title})
|
|
}
|
|
return duplicates
|
|
}
|
|
|
|
func articleTranslationSystemPrompt(kind string, targetLanguage string) string {
|
|
if kind == "title" {
|
|
return fmt.Sprintf(`You are a professional translator.
|
|
Translate the following text to %s.
|
|
Return only the translated text, no explanations or extra formatting.`, targetLanguage)
|
|
}
|
|
return fmt.Sprintf(`You are a professional translator. Translate the following content to %s.
|
|
The content is markdown that may contain embedded HTML blocks.
|
|
Rules:
|
|
- Translate ONLY the visible text content (headings, paragraphs, list items, table cells, etc.).
|
|
- Preserve ALL markdown formatting exactly: headings (#), bold (**), italic (*), links, lists, code blocks, blockquotes, tables, horizontal rules.
|
|
- Preserve ALL HTML tags, attributes, and structure exactly as they are.
|
|
- Do NOT translate or modify: URLs, image src/alt attributes, link href values, class names, IDs, data attributes, code blocks, or any HTML attribute values.
|
|
- Keep all image tags (both markdown  and HTML <img>), iframes, and embedded media completely unchanged.
|
|
- Preserve all line breaks, blank lines, and whitespace patterns.
|
|
- Return ONLY the translated content, no wrapping or explanations.`, targetLanguage)
|
|
}
|
|
|
|
func languageNameForLocale(localeCode string) string {
|
|
localeCode = strings.TrimSpace(localeCode)
|
|
if localeCode == "" {
|
|
return localeCode
|
|
}
|
|
if name, ok := articleTranslationLanguageNames[localeCode]; ok {
|
|
return name
|
|
}
|
|
normalized := strings.ReplaceAll(localeCode, "_", "-")
|
|
if tag, err := language.Parse(normalized); err == nil {
|
|
name := display.English.Tags().Name(tag)
|
|
if name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return localeCode
|
|
}
|
|
|
|
var articleTranslationLanguageNames = map[string]string{
|
|
"ar": "Arabic",
|
|
"de": "German",
|
|
"en": "English",
|
|
"es": "Spanish",
|
|
"fr": "French",
|
|
"hi": "Hindi",
|
|
"id": "Indonesian",
|
|
"it": "Italian",
|
|
"ja": "Japanese",
|
|
"ko": "Korean",
|
|
"nl": "Dutch",
|
|
"pl": "Polish",
|
|
"pt": "Portuguese",
|
|
"pt_BR": "Portuguese (Brazil)",
|
|
"ru": "Russian",
|
|
"th": "Thai",
|
|
"tr": "Turkish",
|
|
"vi": "Vietnamese",
|
|
"zh": "Chinese",
|
|
"zh_CN": "Chinese (China)",
|
|
"zh_TW": "Chinese (Taiwan)",
|
|
}
|
|
|
|
func portalAllowsLocale(portal *model.Portal, locale string) bool {
|
|
if portal == nil || strings.TrimSpace(locale) == "" {
|
|
return false
|
|
}
|
|
var config map[string]any
|
|
if len(portal.PortalConfiguration) > 0 {
|
|
_ = json.Unmarshal(portal.PortalConfiguration, &config)
|
|
}
|
|
if raw, ok := config["allowed_locales"]; ok {
|
|
switch values := raw.(type) {
|
|
case []any:
|
|
for _, value := range values {
|
|
if fmt.Sprint(value) == locale {
|
|
return true
|
|
}
|
|
}
|
|
case []string:
|
|
for _, value := range values {
|
|
if value == locale {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
return portal.Locale == locale
|
|
}
|
|
|
|
func validateArticleBulkStatus(status string) error {
|
|
validStatuses := map[model.ArticleStatus]bool{
|
|
model.ArticleStatusDraft: true,
|
|
model.ArticleStatusPublished: true,
|
|
model.ArticleStatusArchived: true,
|
|
}
|
|
if !validStatuses[model.ArticleStatus(status)] {
|
|
return ErrArticleBulkInvalidStatus
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func articleSlug(title string) string {
|
|
slug := strings.ToLower(strings.TrimSpace(title))
|
|
slug = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(slug, "-")
|
|
slug = strings.Trim(slug, "-")
|
|
if slug == "" {
|
|
slug = "article"
|
|
}
|
|
return fmt.Sprintf("%d-%s", time.Now().UTC().Unix(), slug)
|
|
}
|