using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Reflection; using System.Text.Json; using System.Threading; using Dalamud.Plugin; using MareSynchronos.MareConfiguration; using MareSynchronos.MareConfiguration.Models; using Microsoft.Extensions.Logging; namespace MareSynchronos.Localization; public class LocalizationService { private readonly ILogger _logger; private readonly IDalamudPluginInterface _pluginInterface; private readonly MareConfigService _configService; private readonly Dictionary> _translations = new(); private readonly HashSet _missingLocalizationsLogged = new(StringComparer.Ordinal); private readonly Lock _missingLocalizationsLock = new(); private static readonly JsonSerializerOptions JsonOptions = new() { AllowTrailingCommas = true }; public static LocalizationService? Instance { get; private set; } public LocalizationService(ILogger logger, IDalamudPluginInterface pluginInterface, MareConfigService configService) { _logger = logger; _pluginInterface = pluginInterface; _configService = configService; Instance = this; foreach (var language in Enum.GetValues()) { _translations[language] = LoadLanguage(language); } } public LocalizationLanguage CurrentLanguage => _configService.Current.Language; public static IEnumerable SupportedLanguages => Enum.GetValues(); public string GetString(string fallbackEnglish, params object[] formatArgs) { return GetStringInternal(null, fallbackEnglish, formatArgs); } public string GetString(string key, string fallbackEnglish, params object[] formatArgs) { return GetStringInternal(key, fallbackEnglish, formatArgs); } public string GetLanguageDisplayName(LocalizationLanguage language) { var fallback = language switch { LocalizationLanguage.French => "Français", LocalizationLanguage.English => "English", _ => language.ToString() }; return GetRawString($"Language.DisplayName.{language}", fallback); } public void ReloadLanguage(LocalizationLanguage language) { _translations[language] = LoadLanguage(language); } public void ReloadAll() { foreach (var language in Enum.GetValues()) { ReloadLanguage(language); } } private string GetStringInternal(string? key, string fallbackEnglish, params object[] formatArgs) { var usedKey = string.IsNullOrWhiteSpace(key) ? fallbackEnglish : key; var text = GetRawString(usedKey, fallbackEnglish); if (formatArgs != null && formatArgs.Length > 0) { try { return string.Format(CultureInfo.CurrentCulture, text, formatArgs); } catch (FormatException ex) { _logger.LogWarning(ex, "Localization format mismatch for key {Key} with text '{Text}'", usedKey, text); try { return string.Format(CultureInfo.CurrentCulture, fallbackEnglish, formatArgs); } catch { return fallbackEnglish; } } } return text; } private string GetRawString(string key, string fallbackEnglish) { if (TryGetString(CurrentLanguage, key, out var localized)) { return localized; } LogMissingLocalization(CurrentLanguage, key, fallbackEnglish); if (TryGetString(LocalizationLanguage.English, key, out var english)) { return english; } if (CurrentLanguage != LocalizationLanguage.English) { LogMissingLocalization(LocalizationLanguage.English, key, fallbackEnglish); } return fallbackEnglish; } private bool TryGetString(LocalizationLanguage language, string key, out string value) { var dictionary = GetDictionary(language); if (dictionary.TryGetValue(key, out var text) && !string.IsNullOrWhiteSpace(text)) { value = text; return true; } value = string.Empty; return false; } private Dictionary GetDictionary(LocalizationLanguage language) { if (!_translations.TryGetValue(language, out var dictionary)) { dictionary = LoadLanguage(language); _translations[language] = dictionary; } return dictionary; } private Dictionary LoadLanguage(LocalizationLanguage language) { var baseDirectory = _pluginInterface.AssemblyLocation.DirectoryName ?? string.Empty; var localizationDirectory = Path.Combine(baseDirectory, "Localization"); var languageCode = GetLanguageCode(language); var filePath = Path.Combine(localizationDirectory, $"{languageCode}.json"); Dictionary? translations = null; if (File.Exists(filePath)) { try { using var stream = File.OpenRead(filePath); translations = DeserializeTranslations(stream); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to load localization data from {FilePath}", filePath); } } if (translations == null) { var assembly = Assembly.GetExecutingAssembly(); var resourceName = $"{assembly.GetName().Name}.Localization.{languageCode}.json"; using var resourceStream = assembly.GetManifestResourceStream(resourceName); if (resourceStream != null) { try { translations = DeserializeTranslations(resourceStream); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to load embedded localization resource {Resource}", resourceName); } } } if (translations == null) { _logger.LogDebug("Localization data for {Language} not found on disk or embedded, using empty dictionary.", language); translations = new Dictionary(StringComparer.OrdinalIgnoreCase); } return translations; } private void LogMissingLocalization(LocalizationLanguage language, string key, string fallback) { var marker = $"{language}:{key}"; using var scope = _missingLocalizationsLock.EnterScope(); if (_missingLocalizationsLogged.Contains(marker)) return; _missingLocalizationsLogged.Add(marker); _logger.LogDebug("Missing localization for {Language} ({Key}). Using fallback '{Fallback}'.", language, key, fallback); } private static string GetLanguageCode(LocalizationLanguage language) => language switch { LocalizationLanguage.French => "fr", LocalizationLanguage.English => "en", _ => language.ToString().ToLowerInvariant(), }; private static Dictionary? DeserializeTranslations(Stream stream) { var translations = JsonSerializer.Deserialize>(stream, JsonOptions); return translations != null ? new Dictionary(translations, StringComparer.OrdinalIgnoreCase) : null; } }