mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
432 lines
12 KiB
Go
432 lines
12 KiB
Go
package service
|
||
|
||
import (
|
||
"bugulma/backend/internal/domain"
|
||
"bugulma/backend/internal/localization"
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// TranslationOrchestrationService orchestrates the complete translation workflow
|
||
// This service handles the high-level business logic for translating entities
|
||
type TranslationOrchestrationService struct {
|
||
workflowService *TranslationWorkflowService
|
||
entityLoader *EntityLoaderService
|
||
locService domain.LocalizationService
|
||
translationSvc *TranslationService
|
||
cacheService *TranslationCacheService
|
||
}
|
||
|
||
// NewTranslationOrchestrationService creates a new translation orchestration service
|
||
func NewTranslationOrchestrationService(
|
||
workflowService *TranslationWorkflowService,
|
||
entityLoader *EntityLoaderService,
|
||
locService domain.LocalizationService,
|
||
translationSvc *TranslationService,
|
||
cacheService *TranslationCacheService,
|
||
) *TranslationOrchestrationService {
|
||
return &TranslationOrchestrationService{
|
||
workflowService: workflowService,
|
||
entityLoader: entityLoader,
|
||
locService: locService,
|
||
translationSvc: translationSvc,
|
||
cacheService: cacheService,
|
||
}
|
||
}
|
||
|
||
// OrchestrationResult represents the result of a translation orchestration
|
||
// This is exported so CLI can format and display results
|
||
type OrchestrationResult struct {
|
||
TotalEntities int
|
||
TotalFields int
|
||
Translated int
|
||
Cached int
|
||
Reused int
|
||
Skipped int
|
||
Errors int
|
||
Duration time.Duration
|
||
EntityResults map[string]*EntityTypeResult
|
||
}
|
||
|
||
// EntityTypeResult represents results for a specific entity type
|
||
type EntityTypeResult struct {
|
||
EntityType string
|
||
Processed int
|
||
Translated int
|
||
Cached int
|
||
Reused int
|
||
Skipped int
|
||
Errors int
|
||
}
|
||
|
||
// TranslateAllEntities orchestrates translation for all entities matching the filter
|
||
func (s *TranslationOrchestrationService) TranslateAllEntities(
|
||
ctx context.Context,
|
||
targetLocale, entityTypeFilter string,
|
||
dryRun, allSites bool,
|
||
) (*OrchestrationResult, error) {
|
||
startTime := time.Now()
|
||
result := &OrchestrationResult{
|
||
EntityResults: make(map[string]*EntityTypeResult),
|
||
}
|
||
|
||
// Validate locale
|
||
if targetLocale != "en" && targetLocale != "tt" {
|
||
return nil, fmt.Errorf("invalid locale: %s. Supported locales: en, tt", targetLocale)
|
||
}
|
||
|
||
// Get entity types to process
|
||
entityTypes := localization.GetEntityTypesForFilter(entityTypeFilter)
|
||
if len(entityTypes) == 0 {
|
||
return nil, fmt.Errorf("no valid entity types found for filter: %s", entityTypeFilter)
|
||
}
|
||
|
||
// Process each entity type
|
||
for _, entityType := range entityTypes {
|
||
entityResult, err := s.translateEntityType(ctx, entityType, targetLocale, dryRun, allSites)
|
||
if err != nil {
|
||
// Log error but continue with other entity types
|
||
if entityResult == nil {
|
||
entityResult = &EntityTypeResult{EntityType: entityType, Errors: 1}
|
||
} else {
|
||
entityResult.Errors++
|
||
}
|
||
}
|
||
|
||
result.EntityResults[entityType] = entityResult
|
||
result.TotalEntities += entityResult.Processed
|
||
result.TotalFields += entityResult.Processed * len(localization.GetFieldsForEntityType(entityType))
|
||
result.Translated += entityResult.Translated
|
||
result.Cached += entityResult.Cached
|
||
result.Reused += entityResult.Reused
|
||
result.Skipped += entityResult.Skipped
|
||
result.Errors += entityResult.Errors
|
||
}
|
||
|
||
result.Duration = time.Since(startTime)
|
||
return result, nil
|
||
}
|
||
|
||
// TranslateSingleEntity translates a single entity by ID
|
||
func (s *TranslationOrchestrationService) TranslateSingleEntity(
|
||
ctx context.Context,
|
||
entityType, entityID, targetLocale string,
|
||
dryRun bool,
|
||
) (*EntityTranslationResult, error) {
|
||
// Load the entity
|
||
entity, err := s.entityLoader.LoadEntityByID(entityType, entityID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to load entity %s:%s: %w", entityType, entityID, err)
|
||
}
|
||
|
||
// Get localizable fields
|
||
fields := localization.GetFieldsForEntityType(entityType)
|
||
if len(fields) == 0 {
|
||
return nil, fmt.Errorf("no localizable fields found for entity type: %s", entityType)
|
||
}
|
||
|
||
// Get existing localizations
|
||
localizations, err := s.locService.GetAllLocalizedValues(entityType, entityID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get localizations: %w", err)
|
||
}
|
||
|
||
// Translate all fields
|
||
result := &EntityTranslationResult{}
|
||
for _, field := range fields {
|
||
fieldResult := s.translateField(ctx, entityType, entityID, entity, field, localizations, targetLocale, dryRun)
|
||
|
||
if fieldResult.Error != nil {
|
||
result.Error = fieldResult.Error
|
||
continue
|
||
}
|
||
|
||
if fieldResult.Skipped {
|
||
result.Skipped++
|
||
continue
|
||
}
|
||
|
||
if fieldResult.Cached {
|
||
result.Cached++
|
||
} else if fieldResult.Reused {
|
||
result.Reused++
|
||
} else if fieldResult.Translated {
|
||
result.Translated++
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// translateEntityType processes all entities of a specific type
|
||
func (s *TranslationOrchestrationService) translateEntityType(
|
||
ctx context.Context,
|
||
entityType, targetLocale string,
|
||
dryRun, allSites bool,
|
||
) (*EntityTypeResult, error) {
|
||
result := &EntityTypeResult{EntityType: entityType}
|
||
|
||
// Load entities
|
||
options := localization.LoadOptions{IncludeAllSites: allSites}
|
||
entities, err := s.entityLoader.LoadEntities(entityType, options)
|
||
if err != nil {
|
||
return result, fmt.Errorf("failed to load %s entities: %w", entityType, err)
|
||
}
|
||
|
||
if len(entities) == 0 {
|
||
return result, nil
|
||
}
|
||
|
||
// Get localizable fields
|
||
fields := localization.GetFieldsForEntityType(entityType)
|
||
|
||
// Process each entity
|
||
for _, entity := range entities {
|
||
entityID := s.entityLoader.GetEntityID(entity)
|
||
entityResults, err := s.translateEntity(ctx, entityType, entityID, entity, fields, targetLocale, dryRun)
|
||
if err != nil {
|
||
result.Errors++
|
||
continue
|
||
}
|
||
|
||
result.Processed++
|
||
result.Translated += entityResults.Translated
|
||
result.Cached += entityResults.Cached
|
||
result.Reused += entityResults.Reused
|
||
result.Skipped += entityResults.Skipped
|
||
if entityResults.Error != nil {
|
||
result.Errors++
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// EntityTranslationResult represents results for translating a single entity
|
||
type EntityTranslationResult struct {
|
||
Translated int
|
||
Cached int
|
||
Reused int
|
||
Skipped int
|
||
Error error
|
||
}
|
||
|
||
// translateEntity processes translation for all fields of a single entity
|
||
func (s *TranslationOrchestrationService) translateEntity(
|
||
ctx context.Context,
|
||
entityType, entityID string,
|
||
entity interface{},
|
||
fields []string,
|
||
targetLocale string,
|
||
dryRun bool,
|
||
) (*EntityTranslationResult, error) {
|
||
result := &EntityTranslationResult{}
|
||
|
||
// Get existing localizations
|
||
localizations, err := s.locService.GetAllLocalizedValues(entityType, entityID)
|
||
if err != nil {
|
||
return result, fmt.Errorf("failed to get localizations: %w", err)
|
||
}
|
||
|
||
// Process each field
|
||
for _, field := range fields {
|
||
fieldResult := s.translateField(ctx, entityType, entityID, entity, field, localizations, targetLocale, dryRun)
|
||
|
||
if fieldResult.Error != nil {
|
||
result.Error = fieldResult.Error
|
||
continue
|
||
}
|
||
|
||
if fieldResult.Skipped {
|
||
result.Skipped++
|
||
continue
|
||
}
|
||
|
||
if fieldResult.Cached {
|
||
result.Cached++
|
||
} else if fieldResult.Reused {
|
||
result.Reused++
|
||
} else if fieldResult.Translated {
|
||
result.Translated++
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// FieldTranslationResult represents the result of translating a single field
|
||
type FieldTranslationResult struct {
|
||
Translated bool
|
||
Cached bool
|
||
Reused bool
|
||
Skipped bool
|
||
Error error
|
||
}
|
||
|
||
// translateField handles translation logic for a single field
|
||
func (s *TranslationOrchestrationService) translateField(
|
||
ctx context.Context,
|
||
entityType, entityID string,
|
||
entity interface{},
|
||
field string,
|
||
localizations map[string]map[string]string,
|
||
targetLocale string,
|
||
dryRun bool,
|
||
) FieldTranslationResult {
|
||
result := FieldTranslationResult{}
|
||
|
||
// Get source value and language
|
||
sourceValue, sourceLang := s.getSourceValue(entity, field, localizations)
|
||
if sourceValue == "" {
|
||
result.Skipped = true
|
||
return result
|
||
}
|
||
|
||
// Skip very short content
|
||
if len(strings.TrimSpace(sourceValue)) < 2 {
|
||
result.Skipped = true
|
||
return result
|
||
}
|
||
|
||
// Check if translation already exists (and is not empty)
|
||
if fieldLocs, ok := localizations[field]; ok {
|
||
if existingValue, hasTranslation := fieldLocs[targetLocale]; hasTranslation && strings.TrimSpace(existingValue) != "" {
|
||
result.Skipped = true
|
||
return result
|
||
}
|
||
}
|
||
|
||
// Check for existing translation in database (reuse)
|
||
existingTranslation := s.findExistingTranslationInDB(ctx, entityType, field, targetLocale, sourceValue)
|
||
if existingTranslation != "" {
|
||
if !dryRun {
|
||
if err := s.locService.SetLocalizedValue(entityType, entityID, field, targetLocale, existingTranslation); err != nil {
|
||
result.Error = fmt.Errorf("failed to save reused translation: %w", err)
|
||
return result
|
||
}
|
||
}
|
||
result.Reused = true
|
||
return result
|
||
}
|
||
|
||
// Check cache
|
||
if cached, found, _ := s.cacheService.FindCachedTranslation(ctx, entityType, field, targetLocale, sourceValue); found {
|
||
if !dryRun {
|
||
if err := s.locService.SetLocalizedValue(entityType, entityID, field, targetLocale, cached); err != nil {
|
||
result.Error = fmt.Errorf("failed to save cached translation: %w", err)
|
||
return result
|
||
}
|
||
}
|
||
result.Cached = true
|
||
return result
|
||
}
|
||
|
||
// Perform translation
|
||
if dryRun {
|
||
result.Skipped = true
|
||
return result
|
||
}
|
||
|
||
translated, err := s.translationSvc.Translate(sourceValue, sourceLang, targetLocale)
|
||
if err != nil {
|
||
result.Error = fmt.Errorf("translation failed: %w", err)
|
||
return result
|
||
}
|
||
|
||
if translated == "" {
|
||
result.Error = fmt.Errorf("empty translation received")
|
||
return result
|
||
}
|
||
|
||
// Save translation
|
||
if err := s.locService.SetLocalizedValue(entityType, entityID, field, targetLocale, translated); err != nil {
|
||
result.Error = fmt.Errorf("failed to save translation: %w", err)
|
||
return result
|
||
}
|
||
|
||
// Update cache
|
||
s.cacheService.AddToCache(entityType, field, targetLocale, sourceValue, translated)
|
||
|
||
result.Translated = true
|
||
return result
|
||
}
|
||
|
||
// getSourceValue determines the source value and language for translation
|
||
// It prefers Russian localization, then falls back to entity field value
|
||
func (s *TranslationOrchestrationService) getSourceValue(
|
||
entity interface{},
|
||
field string,
|
||
localizations map[string]map[string]string,
|
||
) (string, string) {
|
||
// Check if Russian localization exists
|
||
if fieldLocs, ok := localizations[field]; ok {
|
||
if ruVal, hasRu := fieldLocs["ru"]; hasRu && ruVal != "" {
|
||
return ruVal, "ru"
|
||
}
|
||
}
|
||
|
||
// Get entity field value
|
||
entityValue := s.entityLoader.GetRussianContent(entity, field)
|
||
if entityValue == "" {
|
||
return "", ""
|
||
}
|
||
|
||
// Check if it's Russian (contains Cyrillic)
|
||
if s.hasCyrillic(entityValue) {
|
||
return entityValue, "ru"
|
||
}
|
||
|
||
// English content - check if we have English localization
|
||
if fieldLocs, ok := localizations[field]; ok {
|
||
if enVal, hasEn := fieldLocs["en"]; hasEn && enVal != "" {
|
||
return enVal, "en"
|
||
}
|
||
}
|
||
|
||
// Use entity field value as English source
|
||
return entityValue, "en"
|
||
}
|
||
|
||
// hasCyrillic checks if a string contains Cyrillic characters or Russian-specific symbols
|
||
func (s *TranslationOrchestrationService) hasCyrillic(text string) bool {
|
||
for _, r := range text {
|
||
// Check for Cyrillic letters (Russian and Tatar)
|
||
if (r >= 'А' && r <= 'Я') || (r >= 'а' && r <= 'я') || r == 'Ё' || r == 'ё' {
|
||
return true
|
||
}
|
||
// Check for Tatar-specific Cyrillic letters
|
||
if r == 'Ә' || r == 'ә' || r == 'Ө' || r == 'ө' || r == 'Ү' || r == 'ү' ||
|
||
r == 'Җ' || r == 'җ' || r == 'Ң' || r == 'ң' || r == 'Һ' || r == 'һ' {
|
||
return true
|
||
}
|
||
// Check for Russian typographic symbols
|
||
// № (U+2116) - Numero sign, commonly used in Russian
|
||
if r == '№' || r == 0x2116 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// findExistingTranslationInDB searches for existing translations in the database
|
||
func (s *TranslationOrchestrationService) findExistingTranslationInDB(
|
||
ctx context.Context,
|
||
entityType, field, targetLocale, sourceText string,
|
||
) string {
|
||
if sourceText == "" {
|
||
return ""
|
||
}
|
||
|
||
normalized := strings.TrimSpace(sourceText)
|
||
|
||
// Use cache service to find existing translation
|
||
// The cache service should check the database for similar translations
|
||
translation, found, _ := s.cacheService.FindCachedTranslation(ctx, entityType, field, targetLocale, normalized)
|
||
if found {
|
||
return translation
|
||
}
|
||
return ""
|
||
}
|