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 } func (s *ArticleService) GetByPortalAndSlug(ctx context.Context, portalID uint, slug string) (*model.Article, error) { article, err := s.repo.FindByPortalAndSlug(ctx, portalID, slug) if err != nil { return nil, fmt.Errorf("get article by slug: %w", err) } return article, 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 ![](url) and HTML ), 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) }