package service import ( "bugulma/backend/internal/domain" "bugulma/backend/internal/localization" "context" "fmt" ) // I18nService provides a unified interface for both UI/UX and data translations // This service is DRY, robust, and easy to use type I18nService struct { locService domain.LocalizationService locRepo domain.LocalizationRepository translationSvc *TranslationService cacheService *TranslationCacheService } // NewI18nService creates a new unified i18n service func NewI18nService( locService domain.LocalizationService, locRepo domain.LocalizationRepository, translationSvc *TranslationService, cacheService *TranslationCacheService, ) *I18nService { return &I18nService{ locService: locService, locRepo: locRepo, translationSvc: translationSvc, cacheService: cacheService, } } // SupportedLocales returns all supported locales var SupportedLocales = []string{"ru", "en", "tt"} // DefaultLocale is the default locale (Russian) const DefaultLocale = "ru" // TranslateData translates data entity fields (sites, organizations, etc.) // This method retrieves existing translations only. For new translations, use TranslateDataWithSource. // This is a convenience method for read-only translation retrieval. func (s *I18nService) TranslateData(ctx context.Context, entityType, entityID, field, targetLocale string) (string, error) { if targetLocale == DefaultLocale { // Russian is the source language, no translation needed return "", fmt.Errorf("target locale cannot be source locale (ru)") } // Return error if locService is not initialized if s.locService == nil { return "", fmt.Errorf("localization service not initialized") } // Get existing translation if available translated, err := s.locService.GetLocalizedValue(entityType, entityID, field, targetLocale) if err != nil { return "", fmt.Errorf("failed to get translation: %w", err) } if translated == "" { return "", fmt.Errorf("no translation found - use TranslateDataWithSource to create new translation") } return translated, nil } // TranslateDataWithSource translates data with provided source text func (s *I18nService) TranslateDataWithSource(ctx context.Context, entityType, entityID, field, sourceText, targetLocale string) (string, error) { if targetLocale == DefaultLocale { return sourceText, nil // No translation needed } // Check cache first if cached, found, _ := s.cacheService.FindCachedTranslation(ctx, entityType, field, targetLocale, sourceText); found { // Save to localization if not already saved if existing, _ := s.getLocalizedValueWithContext(ctx, entityType, entityID, field, targetLocale); existing == "" { _ = s.setLocalizedValueWithContext(ctx, entityType, entityID, field, targetLocale, cached) } return cached, nil } // Check if translation already exists in database existing, err := s.getLocalizedValueWithContext(ctx, entityType, entityID, field, targetLocale) if err == nil && existing != "" { // Cache it s.cacheService.AddToCache(entityType, field, targetLocale, sourceText, existing) return existing, nil } // Perform translation translated, err := s.translationSvc.Translate(sourceText, DefaultLocale, targetLocale) if err != nil { return "", fmt.Errorf("translation failed: %w", err) } // Save translation if err := s.setLocalizedValueWithContext(ctx, entityType, entityID, field, targetLocale, translated); err != nil { return "", fmt.Errorf("failed to save translation: %w", err) } // Cache translation s.cacheService.AddToCache(entityType, field, targetLocale, sourceText, translated) return translated, nil } // GetDataTranslation retrieves a data translation, with fallback to source func (s *I18nService) GetDataTranslation(ctx context.Context, entityType, entityID, field, locale string, fallbackSource string) string { if locale == DefaultLocale { return fallbackSource } translated, err := s.getLocalizedValueWithContext(ctx, entityType, entityID, field, locale) if err == nil && translated != "" { return translated } return fallbackSource } // GetUITranslation retrieves a UI translation by key // UI translations are stored with entityType="ui" and entityID="ui" func (s *I18nService) GetUITranslation(ctx context.Context, key, locale string, fallback string) string { if locale == DefaultLocale { // For Russian, return fallback or key if fallback != "" { return fallback } return key } // UI translations use entityType="ui", entityID="ui", field=key translated, err := s.getLocalizedValueWithContext(ctx, "ui", "ui", key, locale) if err == nil && translated != "" { return translated } // Fallback chain: requested locale -> default locale -> key if fallback != "" { return fallback } return key } // SetUITranslation sets a UI translation func (s *I18nService) SetUITranslation(ctx context.Context, key, locale, value string) error { return s.setLocalizedValueWithContext(ctx, "ui", "ui", key, locale, value) } // GetUITranslationsForLocale retrieves all UI translations for a specific locale func (s *I18nService) GetUITranslationsForLocale(ctx context.Context, locale string) (map[string]string, error) { if !s.ValidateLocale(locale) { return nil, fmt.Errorf("invalid locale: %s", locale) } // Get all UI localizations for this locale // UI translations use entityType="ui", entityID="ui" localizations, err := s.locRepo.GetByEntityTypeAndLocale(ctx, "ui", locale) if err != nil { return nil, fmt.Errorf("failed to retrieve UI translations: %w", err) } // Filter to only "ui" entityID and build map result := make(map[string]string) for _, loc := range localizations { if loc.EntityID == "ui" { result[loc.Field] = loc.Value } } return result, nil } // BatchTranslateUI translates multiple UI keys at once func (s *I18nService) BatchTranslateUI(ctx context.Context, keys []string, sourceLocale, targetLocale string) (map[string]string, error) { if targetLocale == DefaultLocale { return nil, fmt.Errorf("target locale cannot be source locale") } results := make(map[string]string) // Get source texts sourceTexts := make(map[string]string) for _, key := range keys { // Get source text from Russian locale source, err := s.getLocalizedValueWithContext(ctx, "ui", "ui", key, sourceLocale) if err != nil || source == "" { // Try fallback - use key as source source = key } sourceTexts[key] = source } // Extract source texts in order for batch translation sourceTextSlice := make([]string, len(keys)) for i, key := range keys { sourceTextSlice[i] = sourceTexts[key] } // Translate in batch translatedTexts, err := s.translationSvc.BatchTranslate( sourceTextSlice, sourceLocale, targetLocale, ) if err != nil { return nil, fmt.Errorf("batch translation failed: %w", err) } // Map translations back to keys for i, key := range keys { if i < len(translatedTexts) { translated := translatedTexts[i] results[key] = translated // Save translation _ = s.setLocalizedValueWithContext(ctx, "ui", "ui", key, targetLocale, translated) } } return results, nil } // GetLocalizedEntity returns an entity with all fields localized for a specific locale func (s *I18nService) GetLocalizedEntity(ctx context.Context, entity domain.Localizable, locale string) error { return s.applyLocalizationToEntityWithContext(ctx, entity, locale) } // GetSupportedLocales returns all supported locales func (s *I18nService) GetSupportedLocales(ctx context.Context) ([]string, error) { if s.locService == nil { // Return default supported locales if locService is not initialized return SupportedLocales, nil } if locSvc, ok := s.locService.(*LocalizationService); ok { return locSvc.GetAllLocalesWithContext(ctx) } // Fallback to interface method return s.locService.GetAllLocales() } // ValidateLocale validates if a locale is supported func (s *I18nService) ValidateLocale(locale string) bool { for _, supported := range SupportedLocales { if supported == locale { return true } } return false } // GetTranslationStats returns statistics about translations func (s *I18nService) GetTranslationStats(ctx context.Context, entityType string) (map[string]interface{}, error) { stats := make(map[string]interface{}) // Get cache stats if s.cacheService != nil { stats["cache"] = s.cacheService.GetCacheStats() } // Get supported locales (handles nil locService gracefully) locales, err := s.GetSupportedLocales(ctx) if err == nil { stats["available_locales"] = locales // Set default stats when locService is nil if s.locService == nil { stats["total_unique_keys"] = int64(0) stats["translated_counts"] = make(map[string]int64) return stats, nil } } // Get translation counts by entity type and locale if s.locRepo != nil { counts, err := s.locRepo.GetTranslationCountsByEntity(ctx) if err == nil { stats["counts_by_entity"] = counts if entityType != "" { if entityCounts, ok := counts[entityType]; ok { stats["entity_counts"] = entityCounts } } } } // Basic stats structure stats["entity_type"] = entityType stats["supported_locales"] = SupportedLocales stats["default_locale"] = DefaultLocale return stats, nil } // Helper methods to access context-aware methods on the concrete LocalizationService implementation func (s *I18nService) getLocalizedValueWithContext(ctx context.Context, entityType, entityID, field, locale string) (string, error) { if s.locService == nil { return "", fmt.Errorf("localization service not initialized") } if locSvc, ok := s.locService.(*LocalizationService); ok { return locSvc.GetLocalizedValueWithContext(ctx, entityType, entityID, field, locale) } // Fallback to interface method (uses context.Background internally) return s.locService.GetLocalizedValue(entityType, entityID, field, locale) } func (s *I18nService) setLocalizedValueWithContext(ctx context.Context, entityType, entityID, field, locale, value string) error { if s.locService == nil { return fmt.Errorf("localization service not initialized") } if locSvc, ok := s.locService.(*LocalizationService); ok { return locSvc.SetLocalizedValueWithContext(ctx, entityType, entityID, field, locale, value) } // Fallback to interface method (uses context.Background internally) return s.locService.SetLocalizedValue(entityType, entityID, field, locale, value) } // SetDataTranslation sets a data translation (admin only) func (s *I18nService) SetDataTranslation(ctx context.Context, entityType, entityID, field, locale, value string) error { return s.setLocalizedValueWithContext(ctx, entityType, entityID, field, locale, value) } // BulkTranslateData translates multiple entity fields for a target locale. // Returns a map of entityID -> field -> translatedValue func (s *I18nService) BulkTranslateData(ctx context.Context, entityType string, entityIDs []string, targetLocale string, fields []string) (map[string]map[string]string, error) { if !s.ValidateLocale(targetLocale) { return nil, fmt.Errorf("invalid target locale: %s", targetLocale) } if targetLocale == DefaultLocale { return nil, fmt.Errorf("target locale cannot be source locale (%s)", DefaultLocale) } // If no specific entityIDs provided, try to discover entities needing translation using repo helper if len(entityIDs) == 0 { idSet := make(map[string]struct{}) // Use a sensible default limit per field limit := 200 for _, field := range fields { ids, err := s.locRepo.GetEntitiesNeedingTranslation(ctx, entityType, field, targetLocale, limit) if err != nil { return nil, fmt.Errorf("failed to get entities needing translation: %w", err) } for _, id := range ids { idSet[id] = struct{}{} } } for id := range idSet { entityIDs = append(entityIDs, id) } } results := make(map[string]map[string]string) var lastErr error for _, entityID := range entityIDs { for _, field := range fields { // Get raw Russian source from entity table source, err := s.locRepo.GetEntityFieldValue(ctx, entityType, entityID, field) if err != nil { // skip this field but record error lastErr = fmt.Errorf("failed to get source for %s/%s/%s: %w", entityType, entityID, field, err) continue } if source == "" { // nothing to translate continue } // Try to find existing translation for same Russian text (reuse) reused, err := s.locRepo.FindExistingTranslationByRussianText(ctx, entityType, field, targetLocale, source) if err == nil && reused != "" { // Save reused translation for this entity/field if missing _ = s.setLocalizedValueWithContext(ctx, entityType, entityID, field, targetLocale, reused) if results[entityID] == nil { results[entityID] = make(map[string]string) } results[entityID][field] = reused continue } // Translate and persist translated, err := s.TranslateDataWithSource(ctx, entityType, entityID, field, source, targetLocale) if err != nil { lastErr = fmt.Errorf("translation failed for %s/%s/%s: %w", entityType, entityID, field, err) continue } if results[entityID] == nil { results[entityID] = make(map[string]string) } results[entityID][field] = translated } } return results, lastErr } // GetMissingTranslations returns entity IDs per field that are missing translations for target locale func (s *I18nService) GetMissingTranslations(ctx context.Context, entityType, targetLocale string, fields []string, limit int) (map[string][]string, error) { if !s.ValidateLocale(targetLocale) { return nil, fmt.Errorf("invalid target locale: %s", targetLocale) } if limit <= 0 { limit = 100 } result := make(map[string][]string) // If no fields specified, try to get from descriptor if len(fields) == 0 { fields = localization.GetFieldsForEntityType(entityType) } for _, field := range fields { ids, err := s.locRepo.GetEntitiesNeedingTranslation(ctx, entityType, field, targetLocale, limit) if err != nil { return nil, fmt.Errorf("failed to get entities needing translation for field %s: %w", field, err) } result[field] = ids } return result, nil } func (s *I18nService) applyLocalizationToEntityWithContext(ctx context.Context, entity domain.Localizable, locale string) error { if s.locService == nil { return fmt.Errorf("localization service not initialized") } if locSvc, ok := s.locService.(*LocalizationService); ok { return locSvc.ApplyLocalizationToEntityWithContext(ctx, entity, locale) } // Fallback to interface method (uses context.Background internally) return s.locService.ApplyLocalizationToEntity(entity, locale) }