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 "" }