| | 1 | | using System; |
| | 2 | | using System.Collections.Concurrent; |
| | 3 | | using System.Collections.Generic; |
| | 4 | | using System.Globalization; |
| | 5 | | using System.IO; |
| | 6 | | using System.Linq; |
| | 7 | | using System.Reflection; |
| | 8 | | using System.Text.Json; |
| | 9 | | using System.Threading.Tasks; |
| | 10 | | using Jellyfin.Extensions; |
| | 11 | | using Jellyfin.Extensions.Json; |
| | 12 | | using MediaBrowser.Controller.Configuration; |
| | 13 | | using MediaBrowser.Model.Entities; |
| | 14 | | using MediaBrowser.Model.Globalization; |
| | 15 | | using Microsoft.Extensions.Logging; |
| | 16 | |
|
| | 17 | | namespace Emby.Server.Implementations.Localization |
| | 18 | | { |
| | 19 | | /// <summary> |
| | 20 | | /// Class LocalizationManager. |
| | 21 | | /// </summary> |
| | 22 | | public class LocalizationManager : ILocalizationManager |
| | 23 | | { |
| | 24 | | private const string DefaultCulture = "en-US"; |
| | 25 | | private const string RatingsPath = "Emby.Server.Implementations.Localization.Ratings."; |
| | 26 | | private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt"; |
| | 27 | | private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json"; |
| 2 | 28 | | private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly; |
| 2 | 29 | | private static readonly string[] _unratedValues = { "n/a", "unrated", "not rated", "nr" }; |
| | 30 | |
|
| | 31 | | private readonly IServerConfigurationManager _configurationManager; |
| | 32 | | private readonly ILogger<LocalizationManager> _logger; |
| | 33 | |
|
| 48 | 34 | | private readonly Dictionary<string, Dictionary<string, ParentalRating>> _allParentalRatings = |
| 48 | 35 | | new Dictionary<string, Dictionary<string, ParentalRating>>(StringComparer.OrdinalIgnoreCase); |
| | 36 | |
|
| 48 | 37 | | private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries = |
| 48 | 38 | | new ConcurrentDictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase); |
| | 39 | |
|
| 48 | 40 | | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; |
| | 41 | |
|
| 48 | 42 | | private List<CultureDto> _cultures = new List<CultureDto>(); |
| | 43 | |
|
| | 44 | | /// <summary> |
| | 45 | | /// Initializes a new instance of the <see cref="LocalizationManager" /> class. |
| | 46 | | /// </summary> |
| | 47 | | /// <param name="configurationManager">The configuration manager.</param> |
| | 48 | | /// <param name="logger">The logger.</param> |
| | 49 | | public LocalizationManager( |
| | 50 | | IServerConfigurationManager configurationManager, |
| | 51 | | ILogger<LocalizationManager> logger) |
| | 52 | | { |
| 48 | 53 | | _configurationManager = configurationManager; |
| 48 | 54 | | _logger = logger; |
| 48 | 55 | | } |
| | 56 | |
|
| | 57 | | /// <summary> |
| | 58 | | /// Loads all resources into memory. |
| | 59 | | /// </summary> |
| | 60 | | /// <returns><see cref="Task" />.</returns> |
| | 61 | | public async Task LoadAll() |
| | 62 | | { |
| | 63 | | // Extract from the assembly |
| | 64 | | foreach (var resource in _assembly.GetManifestResourceNames()) |
| | 65 | | { |
| | 66 | | if (!resource.StartsWith(RatingsPath, StringComparison.Ordinal)) |
| | 67 | | { |
| | 68 | | continue; |
| | 69 | | } |
| | 70 | |
|
| | 71 | | string countryCode = resource.Substring(RatingsPath.Length, 2); |
| | 72 | | var dict = new Dictionary<string, ParentalRating>(StringComparer.OrdinalIgnoreCase); |
| | 73 | |
|
| | 74 | | var stream = _assembly.GetManifestResourceStream(resource); |
| | 75 | | await using (stream!.ConfigureAwait(false)) // shouldn't be null here, we just got the resource path fro |
| | 76 | | { |
| | 77 | | using var reader = new StreamReader(stream!); |
| | 78 | | await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) |
| | 79 | | { |
| | 80 | | if (string.IsNullOrWhiteSpace(line)) |
| | 81 | | { |
| | 82 | | continue; |
| | 83 | | } |
| | 84 | |
|
| | 85 | | string[] parts = line.Split(','); |
| | 86 | | if (parts.Length == 2 |
| | 87 | | && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) |
| | 88 | | { |
| | 89 | | var name = parts[0]; |
| | 90 | | dict.Add(name, new ParentalRating(name, value)); |
| | 91 | | } |
| | 92 | | else |
| | 93 | | { |
| | 94 | | _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); |
| | 95 | | } |
| | 96 | | } |
| | 97 | | } |
| | 98 | |
|
| | 99 | | _allParentalRatings[countryCode] = dict; |
| | 100 | | } |
| | 101 | |
|
| | 102 | | await LoadCultures().ConfigureAwait(false); |
| | 103 | | } |
| | 104 | |
|
| | 105 | | /// <summary> |
| | 106 | | /// Gets the cultures. |
| | 107 | | /// </summary> |
| | 108 | | /// <returns><see cref="IEnumerable{CultureDto}" />.</returns> |
| | 109 | | public IEnumerable<CultureDto> GetCultures() |
| 1 | 110 | | => _cultures; |
| | 111 | |
|
| | 112 | | private async Task LoadCultures() |
| | 113 | | { |
| | 114 | | List<CultureDto> list = new List<CultureDto>(); |
| | 115 | |
|
| | 116 | | await using var stream = _assembly.GetManifestResourceStream(CulturesPath) |
| | 117 | | ?? throw new InvalidOperationException($"Invalid resource path: '{CulturesPath}'"); |
| | 118 | | using var reader = new StreamReader(stream); |
| | 119 | | await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) |
| | 120 | | { |
| | 121 | | if (string.IsNullOrWhiteSpace(line)) |
| | 122 | | { |
| | 123 | | continue; |
| | 124 | | } |
| | 125 | |
|
| | 126 | | var parts = line.Split('|'); |
| | 127 | |
|
| | 128 | | if (parts.Length == 5) |
| | 129 | | { |
| | 130 | | string name = parts[3]; |
| | 131 | | if (string.IsNullOrWhiteSpace(name)) |
| | 132 | | { |
| | 133 | | continue; |
| | 134 | | } |
| | 135 | |
|
| | 136 | | string twoCharName = parts[2]; |
| | 137 | | if (string.IsNullOrWhiteSpace(twoCharName)) |
| | 138 | | { |
| | 139 | | continue; |
| | 140 | | } |
| | 141 | |
|
| | 142 | | string[] threeletterNames; |
| | 143 | | if (string.IsNullOrWhiteSpace(parts[1])) |
| | 144 | | { |
| | 145 | | threeletterNames = new[] { parts[0] }; |
| | 146 | | } |
| | 147 | | else |
| | 148 | | { |
| | 149 | | threeletterNames = new[] { parts[0], parts[1] }; |
| | 150 | | } |
| | 151 | |
|
| | 152 | | list.Add(new CultureDto(name, name, twoCharName, threeletterNames)); |
| | 153 | | } |
| | 154 | | } |
| | 155 | |
|
| | 156 | | _cultures = list; |
| | 157 | | } |
| | 158 | |
|
| | 159 | | /// <inheritdoc /> |
| | 160 | | public CultureDto? FindLanguageInfo(string language) |
| | 161 | | { |
| | 162 | | // TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs |
| 1150 | 163 | | for (var i = 0; i < _cultures.Count; i++) |
| | 164 | | { |
| 573 | 165 | | var culture = _cultures[i]; |
| 573 | 166 | | if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase) |
| 573 | 167 | | || language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase) |
| 573 | 168 | | || culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase) |
| 573 | 169 | | || language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase)) |
| | 170 | | { |
| 4 | 171 | | return culture; |
| | 172 | | } |
| | 173 | | } |
| | 174 | |
|
| 2 | 175 | | return default; |
| | 176 | | } |
| | 177 | |
|
| | 178 | | /// <inheritdoc /> |
| | 179 | | public IEnumerable<CountryInfo> GetCountries() |
| | 180 | | { |
| 1 | 181 | | using StreamReader reader = new StreamReader( |
| 1 | 182 | | _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid reso |
| 1 | 183 | | return JsonSerializer.Deserialize<IEnumerable<CountryInfo>>(reader.ReadToEnd(), _jsonOptions) |
| 1 | 184 | | ?? throw new InvalidOperationException($"Resource contains invalid data: '{CountriesPath}'"); |
| 1 | 185 | | } |
| | 186 | |
|
| | 187 | | /// <inheritdoc /> |
| | 188 | | public IEnumerable<ParentalRating> GetParentalRatings() |
| | 189 | | { |
| | 190 | | // Use server default language for ratings |
| | 191 | | // Fall back to empty list if there are no parental ratings for that language |
| 2 | 192 | | var ratings = GetParentalRatingsDictionary()?.Values.ToList() |
| 2 | 193 | | ?? new List<ParentalRating>(); |
| | 194 | |
|
| | 195 | | // Add common ratings to ensure them being available for selection |
| | 196 | | // Based on the US rating system due to it being the main source of rating in the metadata providers |
| | 197 | | // Unrated |
| 2 | 198 | | if (!ratings.Any(x => x.Value is null)) |
| | 199 | | { |
| 2 | 200 | | ratings.Add(new ParentalRating("Unrated", null)); |
| | 201 | | } |
| | 202 | |
|
| | 203 | | // Minimum rating possible |
| 2 | 204 | | if (ratings.All(x => x.Value != 0)) |
| | 205 | | { |
| 0 | 206 | | ratings.Add(new ParentalRating("Approved", 0)); |
| | 207 | | } |
| | 208 | |
|
| | 209 | | // Matches PG (this has different age restrictions depending on country) |
| 2 | 210 | | if (ratings.All(x => x.Value != 10)) |
| | 211 | | { |
| 1 | 212 | | ratings.Add(new ParentalRating("10", 10)); |
| | 213 | | } |
| | 214 | |
|
| | 215 | | // Matches PG-13 |
| 2 | 216 | | if (ratings.All(x => x.Value != 13)) |
| | 217 | | { |
| 1 | 218 | | ratings.Add(new ParentalRating("13", 13)); |
| | 219 | | } |
| | 220 | |
|
| | 221 | | // Matches TV-14 |
| 2 | 222 | | if (ratings.All(x => x.Value != 14)) |
| | 223 | | { |
| 1 | 224 | | ratings.Add(new ParentalRating("14", 14)); |
| | 225 | | } |
| | 226 | |
|
| | 227 | | // Catchall if max rating of country is less than 21 |
| | 228 | | // Using 21 instead of 18 to be sure to allow access to all rated content except adult and banned |
| 2 | 229 | | if (!ratings.Any(x => x.Value >= 21)) |
| | 230 | | { |
| 2 | 231 | | ratings.Add(new ParentalRating("21", 21)); |
| | 232 | | } |
| | 233 | |
|
| | 234 | | // A lot of countries don't excplicitly have a seperate rating for adult content |
| 2 | 235 | | if (ratings.All(x => x.Value != 1000)) |
| | 236 | | { |
| 2 | 237 | | ratings.Add(new ParentalRating("XXX", 1000)); |
| | 238 | | } |
| | 239 | |
|
| | 240 | | // A lot of countries don't excplicitly have a seperate rating for banned content |
| 2 | 241 | | if (ratings.All(x => x.Value != 1001)) |
| | 242 | | { |
| 2 | 243 | | ratings.Add(new ParentalRating("Banned", 1001)); |
| | 244 | | } |
| | 245 | |
|
| 2 | 246 | | return ratings.OrderBy(r => r.Value); |
| | 247 | | } |
| | 248 | |
|
| | 249 | | /// <summary> |
| | 250 | | /// Gets the parental ratings dictionary. |
| | 251 | | /// </summary> |
| | 252 | | /// <param name="countryCode">The optional two letter ISO language string.</param> |
| | 253 | | /// <returns><see cref="Dictionary{String, ParentalRating}" />.</returns> |
| | 254 | | private Dictionary<string, ParentalRating>? GetParentalRatingsDictionary(string? countryCode = null) |
| | 255 | | { |
| | 256 | | // Fallback to server default if no country code is specified. |
| 14 | 257 | | if (string.IsNullOrEmpty(countryCode)) |
| | 258 | | { |
| 12 | 259 | | countryCode = _configurationManager.Configuration.MetadataCountryCode; |
| | 260 | | } |
| | 261 | |
|
| 14 | 262 | | if (_allParentalRatings.TryGetValue(countryCode, out var countryValue)) |
| | 263 | | { |
| 13 | 264 | | return countryValue; |
| | 265 | | } |
| | 266 | |
|
| 1 | 267 | | return null; |
| | 268 | | } |
| | 269 | |
|
| | 270 | | /// <inheritdoc /> |
| | 271 | | public int? GetRatingLevel(string rating, string? countryCode = null) |
| | 272 | | { |
| 22 | 273 | | ArgumentException.ThrowIfNullOrEmpty(rating); |
| | 274 | |
|
| | 275 | | // Handle unrated content |
| 22 | 276 | | if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase)) |
| | 277 | | { |
| 4 | 278 | | return null; |
| | 279 | | } |
| | 280 | |
|
| | 281 | | // Convert integers directly |
| | 282 | | // This may override some of the locale specific age ratings (but those always map to the same age) |
| 18 | 283 | | if (int.TryParse(rating, out var ratingAge)) |
| | 284 | | { |
| 7 | 285 | | return ratingAge; |
| | 286 | | } |
| | 287 | |
|
| | 288 | | // Fairly common for some users to have "Rated R" in their rating field |
| 11 | 289 | | rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase); |
| 11 | 290 | | rating = rating.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase); |
| | 291 | |
|
| | 292 | | // Use rating system matching the language |
| 11 | 293 | | if (!string.IsNullOrEmpty(countryCode)) |
| | 294 | | { |
| 1 | 295 | | var ratingsDictionary = GetParentalRatingsDictionary(countryCode); |
| 1 | 296 | | if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRating? value)) |
| | 297 | | { |
| 1 | 298 | | return value.Value; |
| | 299 | | } |
| | 300 | | } |
| | 301 | | else |
| | 302 | | { |
| | 303 | | // Fall back to server default language for ratings check |
| | 304 | | // If it has no ratings, use the US ratings |
| 10 | 305 | | var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us"); |
| 10 | 306 | | if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRating? value)) |
| | 307 | | { |
| 3 | 308 | | return value.Value; |
| | 309 | | } |
| | 310 | | } |
| | 311 | |
|
| | 312 | | // If we don't find anything, check all ratings systems |
| 278 | 313 | | foreach (var dictionary in _allParentalRatings.Values) |
| | 314 | | { |
| 133 | 315 | | if (dictionary.TryGetValue(rating, out var value)) |
| | 316 | | { |
| 2 | 317 | | return value.Value; |
| | 318 | | } |
| | 319 | | } |
| | 320 | |
|
| | 321 | | // Try splitting by : to handle "Germany: FSK-18" |
| 5 | 322 | | if (rating.Contains(':', StringComparison.OrdinalIgnoreCase)) |
| | 323 | | { |
| 2 | 324 | | var ratingLevelRightPart = rating.AsSpan().RightPart(':'); |
| 2 | 325 | | if (ratingLevelRightPart.Length != 0) |
| | 326 | | { |
| 1 | 327 | | return GetRatingLevel(ratingLevelRightPart.ToString()); |
| | 328 | | } |
| | 329 | | } |
| | 330 | |
|
| | 331 | | // Handle prefix country code to handle "DE-18" |
| 4 | 332 | | if (rating.Contains('-', StringComparison.OrdinalIgnoreCase)) |
| | 333 | | { |
| 3 | 334 | | var ratingSpan = rating.AsSpan(); |
| | 335 | |
|
| | 336 | | // Extract culture from country prefix |
| 3 | 337 | | var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString()); |
| | 338 | |
|
| 3 | 339 | | var ratingLevelRightPart = ratingSpan.RightPart('-'); |
| 3 | 340 | | if (ratingLevelRightPart.Length != 0) |
| | 341 | | { |
| | 342 | | // Check rating system of culture |
| 2 | 343 | | return GetRatingLevel(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName); |
| | 344 | | } |
| | 345 | | } |
| | 346 | |
|
| 2 | 347 | | return null; |
| 2 | 348 | | } |
| | 349 | |
|
| | 350 | | /// <inheritdoc /> |
| | 351 | | public string GetLocalizedString(string phrase) |
| | 352 | | { |
| 517 | 353 | | return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture); |
| | 354 | | } |
| | 355 | |
|
| | 356 | | /// <inheritdoc /> |
| | 357 | | public string GetLocalizedString(string phrase, string culture) |
| | 358 | | { |
| 517 | 359 | | if (string.IsNullOrEmpty(culture)) |
| | 360 | | { |
| 0 | 361 | | culture = _configurationManager.Configuration.UICulture; |
| | 362 | | } |
| | 363 | |
|
| 517 | 364 | | if (string.IsNullOrEmpty(culture)) |
| | 365 | | { |
| 0 | 366 | | culture = DefaultCulture; |
| | 367 | | } |
| | 368 | |
|
| 517 | 369 | | var dictionary = GetLocalizationDictionary(culture); |
| | 370 | |
|
| 517 | 371 | | if (dictionary.TryGetValue(phrase, out var value)) |
| | 372 | | { |
| 494 | 373 | | return value; |
| | 374 | | } |
| | 375 | |
|
| 23 | 376 | | return phrase; |
| | 377 | | } |
| | 378 | |
|
| | 379 | | private Dictionary<string, string> GetLocalizationDictionary(string culture) |
| | 380 | | { |
| 517 | 381 | | ArgumentException.ThrowIfNullOrEmpty(culture); |
| | 382 | |
|
| | 383 | | const string Prefix = "Core"; |
| | 384 | |
|
| 517 | 385 | | return _dictionaries.GetOrAdd( |
| 517 | 386 | | culture, |
| 517 | 387 | | static (key, localizationManager) => localizationManager.GetDictionary(Prefix, key, DefaultCulture + ".j |
| 517 | 388 | | this); |
| | 389 | | } |
| | 390 | |
|
| | 391 | | private async Task<Dictionary<string, string>> GetDictionary(string prefix, string culture, string baseFilename) |
| | 392 | | { |
| | 393 | | ArgumentException.ThrowIfNullOrEmpty(culture); |
| | 394 | |
|
| | 395 | | var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| | 396 | |
|
| | 397 | | var namespaceName = GetType().Namespace + "." + prefix; |
| | 398 | |
|
| | 399 | | await CopyInto(dictionary, namespaceName + "." + baseFilename).ConfigureAwait(false); |
| | 400 | | await CopyInto(dictionary, namespaceName + "." + GetResourceFilename(culture)).ConfigureAwait(false); |
| | 401 | |
|
| | 402 | | return dictionary; |
| | 403 | | } |
| | 404 | |
|
| | 405 | | private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath) |
| | 406 | | { |
| | 407 | | await using var stream = _assembly.GetManifestResourceStream(resourcePath); |
| | 408 | | // If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the c |
| | 409 | | if (stream is null) |
| | 410 | | { |
| | 411 | | _logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath); |
| | 412 | | return; |
| | 413 | | } |
| | 414 | |
|
| | 415 | | var dict = await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(stream, _jsonOptions).Configure |
| | 416 | | if (dict is null) |
| | 417 | | { |
| | 418 | | throw new InvalidOperationException($"Resource contains invalid data: '{stream}'"); |
| | 419 | | } |
| | 420 | |
|
| | 421 | | foreach (var key in dict.Keys) |
| | 422 | | { |
| | 423 | | dictionary[key] = dict[key]; |
| | 424 | | } |
| | 425 | | } |
| | 426 | |
|
| | 427 | | private static string GetResourceFilename(string culture) |
| | 428 | | { |
| 26 | 429 | | var parts = culture.Split('-'); |
| | 430 | |
|
| 26 | 431 | | if (parts.Length == 2) |
| | 432 | | { |
| 25 | 433 | | culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant(); |
| | 434 | | } |
| | 435 | | else |
| | 436 | | { |
| 1 | 437 | | culture = culture.ToLowerInvariant(); |
| | 438 | | } |
| | 439 | |
|
| 26 | 440 | | return culture + ".json"; |
| | 441 | | } |
| | 442 | |
|
| | 443 | | /// <inheritdoc /> |
| | 444 | | public IEnumerable<LocalizationOption> GetLocalizationOptions() |
| | 445 | | { |
| | 446 | | yield return new LocalizationOption("Afrikaans", "af"); |
| | 447 | | yield return new LocalizationOption("العربية", "ar"); |
| | 448 | | yield return new LocalizationOption("Беларуская", "be"); |
| | 449 | | yield return new LocalizationOption("Български", "bg-BG"); |
| | 450 | | yield return new LocalizationOption("বাংলা (বাংলাদেশ)", "bn"); |
| | 451 | | yield return new LocalizationOption("Català", "ca"); |
| | 452 | | yield return new LocalizationOption("Čeština", "cs"); |
| | 453 | | yield return new LocalizationOption("Cymraeg", "cy"); |
| | 454 | | yield return new LocalizationOption("Dansk", "da"); |
| | 455 | | yield return new LocalizationOption("Deutsch", "de"); |
| | 456 | | yield return new LocalizationOption("English (United Kingdom)", "en-GB"); |
| | 457 | | yield return new LocalizationOption("English", "en-US"); |
| | 458 | | yield return new LocalizationOption("Ελληνικά", "el"); |
| | 459 | | yield return new LocalizationOption("Esperanto", "eo"); |
| | 460 | | yield return new LocalizationOption("Español", "es"); |
| | 461 | | yield return new LocalizationOption("Español americano", "es_419"); |
| | 462 | | yield return new LocalizationOption("Español (Argentina)", "es-AR"); |
| | 463 | | yield return new LocalizationOption("Español (Dominicana)", "es_DO"); |
| | 464 | | yield return new LocalizationOption("Español (México)", "es-MX"); |
| | 465 | | yield return new LocalizationOption("Eesti", "et"); |
| | 466 | | yield return new LocalizationOption("Basque", "eu"); |
| | 467 | | yield return new LocalizationOption("فارسی", "fa"); |
| | 468 | | yield return new LocalizationOption("Suomi", "fi"); |
| | 469 | | yield return new LocalizationOption("Filipino", "fil"); |
| | 470 | | yield return new LocalizationOption("Français", "fr"); |
| | 471 | | yield return new LocalizationOption("Français (Canada)", "fr-CA"); |
| | 472 | | yield return new LocalizationOption("Galego", "gl"); |
| | 473 | | yield return new LocalizationOption("Schwiizerdütsch", "gsw"); |
| | 474 | | yield return new LocalizationOption("עִבְרִית", "he"); |
| | 475 | | yield return new LocalizationOption("हिन्दी", "hi"); |
| | 476 | | yield return new LocalizationOption("Hrvatski", "hr"); |
| | 477 | | yield return new LocalizationOption("Magyar", "hu"); |
| | 478 | | yield return new LocalizationOption("Bahasa Indonesia", "id"); |
| | 479 | | yield return new LocalizationOption("Íslenska", "is"); |
| | 480 | | yield return new LocalizationOption("Italiano", "it"); |
| | 481 | | yield return new LocalizationOption("日本語", "ja"); |
| | 482 | | yield return new LocalizationOption("Qazaqşa", "kk"); |
| | 483 | | yield return new LocalizationOption("한국어", "ko"); |
| | 484 | | yield return new LocalizationOption("Lietuvių", "lt"); |
| | 485 | | yield return new LocalizationOption("Latviešu", "lv"); |
| | 486 | | yield return new LocalizationOption("Македонски", "mk"); |
| | 487 | | yield return new LocalizationOption("മലയാളം", "ml"); |
| | 488 | | yield return new LocalizationOption("मराठी", "mr"); |
| | 489 | | yield return new LocalizationOption("Bahasa Melayu", "ms"); |
| | 490 | | yield return new LocalizationOption("Norsk bokmål", "nb"); |
| | 491 | | yield return new LocalizationOption("नेपाली", "ne"); |
| | 492 | | yield return new LocalizationOption("Nederlands", "nl"); |
| | 493 | | yield return new LocalizationOption("Norsk nynorsk", "nn"); |
| | 494 | | yield return new LocalizationOption("ਪੰਜਾਬੀ", "pa"); |
| | 495 | | yield return new LocalizationOption("Polski", "pl"); |
| | 496 | | yield return new LocalizationOption("Pirate", "pr"); |
| | 497 | | yield return new LocalizationOption("Português", "pt"); |
| | 498 | | yield return new LocalizationOption("Português (Brasil)", "pt-BR"); |
| | 499 | | yield return new LocalizationOption("Português (Portugal)", "pt-PT"); |
| | 500 | | yield return new LocalizationOption("Românește", "ro"); |
| | 501 | | yield return new LocalizationOption("Русский", "ru"); |
| | 502 | | yield return new LocalizationOption("Slovenčina", "sk"); |
| | 503 | | yield return new LocalizationOption("Slovenščina", "sl-SI"); |
| | 504 | | yield return new LocalizationOption("Shqip", "sq"); |
| | 505 | | yield return new LocalizationOption("Српски", "sr"); |
| | 506 | | yield return new LocalizationOption("Svenska", "sv"); |
| | 507 | | yield return new LocalizationOption("தமிழ்", "ta"); |
| | 508 | | yield return new LocalizationOption("తెలుగు", "te"); |
| | 509 | | yield return new LocalizationOption("ภาษาไทย", "th"); |
| | 510 | | yield return new LocalizationOption("Türkçe", "tr"); |
| | 511 | | yield return new LocalizationOption("Українська", "uk"); |
| | 512 | | yield return new LocalizationOption("اُردُو", "ur_PK"); |
| | 513 | | yield return new LocalizationOption("Tiếng Việt", "vi"); |
| | 514 | | yield return new LocalizationOption("汉语 (简体字)", "zh-CN"); |
| | 515 | | yield return new LocalizationOption("漢語 (繁體字)", "zh-TW"); |
| | 516 | | yield return new LocalizationOption("廣東話 (香港)", "zh-HK"); |
| | 517 | | } |
| | 518 | | } |
| | 519 | | } |