New: Add UI Localization Framework

This commit is contained in:
Michael Casey 2021-08-10 07:13:33 +10:00 committed by Mark McDowall
parent 1977f4aa3c
commit 5938a95abb
17 changed files with 557 additions and 71 deletions

View File

@ -5,25 +5,14 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { reprocessInteractiveImportItems, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions';
import { fetchLanguageProfileSchema } from 'Store/Actions/settingsActions';
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import SelectLanguageModalContent from './SelectLanguageModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.languageProfiles,
(languageProfiles) => {
const {
isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
schema
} = languageProfiles;
return {
isFetching,
isPopulated,
error,
items: schema.languages ? [...schema.languages].reverse() : []
};
createLanguagesSelector(),
(languages) => {
return languages;
}
);
}

View File

@ -12,6 +12,7 @@ import { inputTypes } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import themes from 'Styles/Themes';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
export const firstDayOfWeekOptions = [
{ key: 0, value: 'Sunday' },
@ -57,6 +58,7 @@ class UISettings extends Component {
hasSettings,
onInputChange,
onSavePress,
languages,
...otherProps
} = this.props;
@ -72,17 +74,19 @@ class UISettings extends Component {
<PageContentBody>
{
isFetching &&
<LoadingIndicator />
isFetching ?
<LoadingIndicator /> :
null
}
{
!isFetching && error &&
<div>Unable to load UI settings</div>
!isFetching && error ?
<div>Unable to load UI settings</div> :
null
}
{
hasSettings && !isFetching && !error &&
hasSettings && !isFetching && !error ?
<Form
id="uiSettings"
{...otherProps}
@ -191,7 +195,23 @@ class UISettings extends Component {
/>
</FormGroup>
</FieldSet>
</Form>
<FieldSet legend={translate('Language')}>
<FormGroup>
<FormLabel>{translate('UI Language')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="uiLanguage"
values={languages}
helpText={translate('Language that Sonarr will use for UI')}
helpTextWarning={translate('Browser Reload Required')}
onChange={onInputChange}
{...settings.uiLanguage}
/>
</FormGroup>
</FieldSet>
</Form> :
null
}
</PageContentBody>
</PageContent>
@ -205,6 +225,7 @@ UISettings.propTypes = {
error: PropTypes.object,
settings: PropTypes.object.isRequired,
hasSettings: PropTypes.bool.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
onSavePress: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired
};

View File

@ -3,30 +3,63 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { fetchUISettings, saveUISettings, setUISettingsValue } from 'Store/Actions/settingsActions';
import { fetchLanguageProfileSchema, fetchUISettings, saveUISettings, setUISettingsValue } from 'Store/Actions/settingsActions';
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import UISettings from './UISettings';
const SECTION = 'ui';
const FILTER_LANGUAGES = ['Any', 'Unknown'];
function createFilteredLanguagesSelector() {
return createSelector(
createLanguagesSelector(),
(languages) => {
if (!languages || !languages.items) {
return [];
}
const newItems = languages.items
.filter((lang) => !FILTER_LANGUAGES.includes(lang.language.name))
.map((item) => {
return {
key: item.language.id,
value: item.language.name
};
});
return {
...languages,
items: newItems
};
}
);
}
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
createSettingsSectionSelector(SECTION),
(advancedSettings, sectionSettings) => {
createFilteredLanguagesSelector(),
(advancedSettings, sectionSettings, languages) => {
return {
advancedSettings,
...sectionSettings
languages: languages.items,
isLanguagesPopulated: languages.isPopulated,
...sectionSettings,
isFetching: sectionSettings.isFetching || languages.isFetching,
error: sectionSettings.error || languages.error
};
}
);
}
const mapDispatchToProps = {
setUISettingsValue,
saveUISettings,
fetchUISettings,
clearPendingChanges
dispatchSetUISettingsValue: setUISettingsValue,
dispatchSaveUISettings: saveUISettings,
dispatchFetchUISettings: fetchUISettings,
dispatchClearPendingChanges: clearPendingChanges,
dispatchFetchLanguageProfileSchema: fetchLanguageProfileSchema
};
class UISettingsConnector extends Component {
@ -35,22 +68,32 @@ class UISettingsConnector extends Component {
// Lifecycle
componentDidMount() {
this.props.fetchUISettings();
const {
isLanguagesPopulated,
dispatchFetchUISettings,
dispatchFetchLanguageProfileSchema
} = this.props;
dispatchFetchUISettings();
if (!isLanguagesPopulated) {
dispatchFetchLanguageProfileSchema();
}
}
componentWillUnmount() {
this.props.clearPendingChanges({ section: `settings.${SECTION}` });
this.props.dispatchClearPendingChanges({ section: `settings.${SECTION}` });
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setUISettingsValue({ name, value });
this.props.dispatchSetUISettingsValue({ name, value });
};
onSavePress = () => {
this.props.saveUISettings();
this.props.dispatchSaveUISettings();
};
//
@ -68,10 +111,12 @@ class UISettingsConnector extends Component {
}
UISettingsConnector.propTypes = {
setUISettingsValue: PropTypes.func.isRequired,
saveUISettings: PropTypes.func.isRequired,
fetchUISettings: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
isLanguagesPopulated: PropTypes.bool.isRequired,
dispatchSetUISettingsValue: PropTypes.func.isRequired,
dispatchSaveUISettings: PropTypes.func.isRequired,
dispatchFetchUISettings: PropTypes.func.isRequired,
dispatchClearPendingChanges: PropTypes.func.isRequired,
dispatchFetchLanguageProfileSchema: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(UISettingsConnector);

View File

@ -0,0 +1,24 @@
import { createSelector } from 'reselect';
function createLanguagesSelector() {
return createSelector(
(state) => state.settings.languageProfiles,
(languageProfiles) => {
const {
isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
schema
} = languageProfiles;
return {
isFetching,
isPopulated,
error,
items: schema.languages ? [...schema.languages].reverse() : []
};
}
);
}
export default createLanguagesSelector;

View File

@ -0,0 +1,28 @@
import createAjaxRequest from 'Utilities/createAjaxRequest';
function getTranslations() {
return createAjaxRequest({
global: false,
dataType: 'json',
url: '/localization'
}).request;
}
let translations = {};
getTranslations().then((data) => {
translations = data.strings;
});
export default function translate(key, tokens) {
const translation = translations[key] || key;
if (tokens) {
return translation.replace(
/\{([a-z0-9]+?)\}/gi,
(match, tokenMatch) => String(tokens[tokenMatch]) ?? match
);
}
return translation;
}

View File

@ -0,0 +1,88 @@
using System;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Localization;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.Localization
{
[TestFixture]
public class LocalizationServiceFixture : CoreTest<LocalizationService>
{
[SetUp]
public void Setup()
{
Mocker.GetMock<IConfigService>().Setup(m => m.UILanguage).Returns((int)Language.English);
Mocker.GetMock<IAppFolderInfo>().Setup(m => m.StartUpFolder).Returns(TestContext.CurrentContext.TestDirectory);
}
[Test]
public void should_get_string_in_dictionary_if_lang_exists_and_string_exists()
{
var localizedString = Subject.GetLocalizedString("UI Language");
localizedString.Should().Be("UI Language");
}
[Test]
public void should_get_string_in_default_language_dictionary_if_no_lang_country_code_exists_and_string_exists()
{
var localizedString = Subject.GetLocalizedString("UI Language", "fr_fr");
localizedString.Should().Be("UI Langue");
ExceptionVerification.ExpectedErrors(1);
}
[Test]
public void should_get_string_in_default_dictionary_if_no_lang_exists_and_string_exists()
{
var localizedString = Subject.GetLocalizedString("UI Language", "an");
localizedString.Should().Be("UI Language");
ExceptionVerification.ExpectedErrors(1);
}
[Test]
public void should_get_string_in_default_dictionary_if_lang_empty_and_string_exists()
{
var localizedString = Subject.GetLocalizedString("UI Language", "");
localizedString.Should().Be("UI Language");
}
[Test]
public void should_return_argument_if_string_doesnt_exists()
{
var localizedString = Subject.GetLocalizedString("badString", "en");
localizedString.Should().Be("badString");
}
[Test]
public void should_return_argument_if_string_doesnt_exists_default_lang()
{
var localizedString = Subject.GetLocalizedString("badString");
localizedString.Should().Be("badString");
}
[Test]
public void should_throw_if_empty_string_passed()
{
Assert.Throws<ArgumentNullException>(() => Subject.GetLocalizedString(""));
}
[Test]
public void should_throw_if_null_string_passed()
{
Assert.Throws<ArgumentNullException>(() => Subject.GetLocalizedString(null));
}
}
}

View File

@ -6,6 +6,7 @@ using NLog;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Http.Proxy;
using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.EpisodeImport;
using NzbDrone.Core.Messaging.Events;
@ -304,6 +305,13 @@ namespace NzbDrone.Core.Configuration
set { SetValue("EnableColorImpairedMode", value); }
}
public int UILanguage
{
get { return GetValueInt("UILanguage", (int)Language.English); }
set { SetValue("UILanguage", value); }
}
public bool CleanupMetadataImages
{
get { return GetValueBoolean("CleanupMetadataImages", true); }

View File

@ -58,6 +58,7 @@ namespace NzbDrone.Core.Configuration
string TimeFormat { get; set; }
bool ShowRelativeDates { get; set; }
bool EnableColorImpairedMode { get; set; }
int UILanguage { get; set; }
//Internal
bool CleanupMetadataImages { get; set; }

View File

@ -0,0 +1,6 @@
{
"Language": "Language",
"UI Language": "UI Language",
"Language that Sonarr will use for UI": "Language that Sonarr will use for UI",
"Browser Reload Required": "Browser Reload Required"
}

View File

@ -0,0 +1,6 @@
{
"Language": "Langue",
"UI Language": "UI Langue",
"Language that Sonarr will use for UI": "Langue que Sonarr utilisera pour l'interface utilisateur",
"Browser Reload Required": "Rechargement du navigateur requis"
}

View File

@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using NLog;
using NzbDrone.Common.Cache;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Parser;
namespace NzbDrone.Core.Localization
{
public interface ILocalizationService
{
Dictionary<string, string> GetLocalizationDictionary();
string GetLocalizedString(string phrase);
string GetLocalizedString(string phrase, string language);
}
public class LocalizationService : ILocalizationService, IHandleAsync<ConfigSavedEvent>
{
private const string DefaultCulture = "en";
private readonly ICached<Dictionary<string, string>> _cache;
private readonly IConfigService _configService;
private readonly IAppFolderInfo _appFolderInfo;
private readonly Logger _logger;
public LocalizationService(IConfigService configService,
IAppFolderInfo appFolderInfo,
ICacheManager cacheManager,
Logger logger)
{
_configService = configService;
_appFolderInfo = appFolderInfo;
_cache = cacheManager.GetCache<Dictionary<string, string>>(typeof(Dictionary<string, string>), "localization");
_logger = logger;
}
public Dictionary<string, string> GetLocalizationDictionary()
{
var language = GetSetLanguageFileName();
return GetLocalizationDictionary(language);
}
public string GetLocalizedString(string phrase)
{
var language = GetSetLanguageFileName();
return GetLocalizedString(phrase, language);
}
public string GetLocalizedString(string phrase, string language)
{
if (string.IsNullOrEmpty(phrase))
{
throw new ArgumentNullException(nameof(phrase));
}
if (language.IsNullOrWhiteSpace())
{
language = GetSetLanguageFileName();
}
if (language == null)
{
language = DefaultCulture;
}
var dictionary = GetLocalizationDictionary(language);
if (dictionary.TryGetValue(phrase, out var value))
{
return value;
}
return phrase;
}
private string GetSetLanguageFileName()
{
var isoLanguage = IsoLanguages.Get((Language)_configService.UILanguage);
var language = isoLanguage.TwoLetterCode;
if (isoLanguage.CountryCode.IsNotNullOrWhiteSpace())
{
language = string.Format("{0}_{1}", language, isoLanguage.CountryCode);
}
return language;
}
private Dictionary<string, string> GetLocalizationDictionary(string language)
{
if (string.IsNullOrEmpty(language))
{
throw new ArgumentNullException(nameof(language));
}
var startupFolder = _appFolderInfo.StartUpFolder;
var prefix = Path.Combine(startupFolder, "Localization", "Core");
var key = prefix + language;
return _cache.Get("localization", () => GetDictionary(prefix, language, DefaultCulture + ".json").GetAwaiter().GetResult());
}
private async Task<Dictionary<string, string>> GetDictionary(string prefix, string culture, string baseFilename)
{
if (string.IsNullOrEmpty(culture))
{
throw new ArgumentNullException(nameof(culture));
}
var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var baseFilenamePath = Path.Combine(prefix, baseFilename);
var alternativeFilenamePath = Path.Combine(prefix, GetResourceFilename(culture));
await CopyInto(dictionary, baseFilenamePath).ConfigureAwait(false);
if (culture.Contains("_"))
{
var languageBaseFilenamePath = Path.Combine(prefix, GetResourceFilename(culture.Split('_')[0]));
await CopyInto(dictionary, languageBaseFilenamePath).ConfigureAwait(false);
}
await CopyInto(dictionary, alternativeFilenamePath).ConfigureAwait(false);
return dictionary;
}
private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath)
{
if (!File.Exists(resourcePath))
{
_logger.Error("Missing translation/culture resource: {0}", resourcePath);
return;
}
using var fs = File.OpenText(resourcePath);
var json = await fs.ReadToEndAsync();
var dict = Json.Deserialize<Dictionary<string, string>>(json);
foreach (var key in dict.Keys)
{
dictionary[key] = dict[key];
}
}
private static string GetResourceFilename(string culture)
{
var parts = culture.Split('_');
if (parts.Length == 2)
{
culture = parts[0].ToLowerInvariant() + "_" + parts[1].ToUpperInvariant();
}
else
{
culture = culture.ToLowerInvariant();
}
return culture + ".json";
}
public void HandleAsync(ConfigSavedEvent message)
{
_cache.Clear();
}
}
}

View File

@ -6,12 +6,14 @@ namespace NzbDrone.Core.Parser
{
public string TwoLetterCode { get; set; }
public string ThreeLetterCode { get; set; }
public string CountryCode { get; set; }
public Language Language { get; set; }
public IsoLanguage(string twoLetterCode, string threeLetterCode, Language language)
public IsoLanguage(string twoLetterCode, string countryCode, string threeLetterCode, Language language)
{
TwoLetterCode = twoLetterCode;
ThreeLetterCode = threeLetterCode;
CountryCode = countryCode;
Language = language;
}
}

View File

@ -10,38 +10,37 @@ namespace NzbDrone.Core.Parser
{
private static readonly HashSet<IsoLanguage> All = new HashSet<IsoLanguage>
{
new IsoLanguage("en", "eng", Language.English),
new IsoLanguage("fr", "fra", Language.French),
new IsoLanguage("es", "spa", Language.Spanish),
new IsoLanguage("de", "deu", Language.German),
new IsoLanguage("it", "ita", Language.Italian),
new IsoLanguage("da", "dan", Language.Danish),
new IsoLanguage("nl", "nld", Language.Dutch),
new IsoLanguage("ja", "jpn", Language.Japanese),
new IsoLanguage("is", "isl", Language.Icelandic),
new IsoLanguage("zh", "zho", Language.Chinese),
new IsoLanguage("ru", "rus", Language.Russian),
new IsoLanguage("pl", "pol", Language.Polish),
new IsoLanguage("vi", "vie", Language.Vietnamese),
new IsoLanguage("sv", "swe", Language.Swedish),
new IsoLanguage("no", "nor", Language.Norwegian),
new IsoLanguage("nb", "nob", Language.Norwegian), // Norwegian Bokmål
new IsoLanguage("fi", "fin", Language.Finnish),
new IsoLanguage("tr", "tur", Language.Turkish),
new IsoLanguage("pt", "por", Language.Portuguese),
// new IsoLanguage("nl", "nld", Language.Flemish),
new IsoLanguage("el", "ell", Language.Greek),
new IsoLanguage("ko", "kor", Language.Korean),
new IsoLanguage("hu", "hun", Language.Hungarian),
new IsoLanguage("he", "heb", Language.Hebrew),
new IsoLanguage("lt", "lit", Language.Lithuanian),
new IsoLanguage("cs", "ces", Language.Czech),
new IsoLanguage("ar", "ara", Language.Arabic),
new IsoLanguage("hi", "hin", Language.Hindi),
new IsoLanguage("bg", "bul", Language.Bulgarian),
new IsoLanguage("ml", "mal", Language.Malayalam),
new IsoLanguage("uk", "ukr", Language.Ukrainian),
new IsoLanguage("en", "", "eng", Language.English),
new IsoLanguage("fr", "fr", "fra", Language.French),
new IsoLanguage("es", "", "spa", Language.Spanish),
new IsoLanguage("de", "de", "deu", Language.German),
new IsoLanguage("it", "", "ita", Language.Italian),
new IsoLanguage("da", "", "dan", Language.Danish),
new IsoLanguage("nl", "", "nld", Language.Dutch),
new IsoLanguage("ja", "", "jpn", Language.Japanese),
new IsoLanguage("is", "", "isl", Language.Icelandic),
new IsoLanguage("zh", "cn", "zho", Language.Chinese),
new IsoLanguage("ru", "", "rus", Language.Russian),
new IsoLanguage("pl", "", "pol", Language.Polish),
new IsoLanguage("vi", "", "vie", Language.Vietnamese),
new IsoLanguage("sv", "", "swe", Language.Swedish),
new IsoLanguage("no", "", "nor", Language.Norwegian),
new IsoLanguage("nb", "", "nob", Language.Norwegian), // Norwegian Bokmål
new IsoLanguage("fi", "", "fin", Language.Finnish),
new IsoLanguage("tr", "", "tur", Language.Turkish),
new IsoLanguage("pt", "pt", "por", Language.Portuguese),
new IsoLanguage("nl", "", "nld", Language.Flemish),
new IsoLanguage("el", "", "ell", Language.Greek),
new IsoLanguage("ko", "", "kor", Language.Korean),
new IsoLanguage("hu", "", "hun", Language.Hungarian),
new IsoLanguage("he", "", "heb", Language.Hebrew),
new IsoLanguage("lt", "", "lit", Language.Lithuanian),
new IsoLanguage("cs", "", "ces", Language.Czech),
new IsoLanguage("ar", "", "ara", Language.Arabic),
new IsoLanguage("hi", "", "hin", Language.Hindi),
new IsoLanguage("bg", "", "bul", Language.Bulgarian),
new IsoLanguage("ml", "", "mal", Language.Malayalam),
new IsoLanguage("uk", "", "ukr", Language.Ukrainian),
};
public static IsoLanguage Find(string isoCode)

View File

@ -30,4 +30,9 @@
<Link>Resources\Logo\64.png</Link>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Content Include="Localization\Core\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -1,4 +1,4 @@
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Configuration;
using Sonarr.Http.REST;
namespace Sonarr.Api.V3.Config
@ -17,6 +17,7 @@ namespace Sonarr.Api.V3.Config
public bool EnableColorImpairedMode { get; set; }
public string Theme { get; set; }
public int UILanguage { get; set; }
}
public static class UiConfigResourceMapper
@ -34,7 +35,8 @@ namespace Sonarr.Api.V3.Config
ShowRelativeDates = model.ShowRelativeDates,
EnableColorImpairedMode = model.EnableColorImpairedMode,
Theme = config.Theme
Theme = config.Theme,
UILanguage = model.UILanguage
};
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Localization;
using Sonarr.Http;
using Sonarr.Http.REST;
namespace Sonarr.Api.V3.Localization
{
[V3ApiController]
public class LocalizationController : RestController<LocalizationResource>
{
private readonly ILocalizationService _localizationService;
public LocalizationController(ILocalizationService localizationService)
{
_localizationService = localizationService;
}
protected override LocalizationResource GetResourceById(int id)
{
return GetLocalization();
}
[HttpGet]
[Produces("application/json")]
public LocalizationResource GetLocalization()
{
return _localizationService.GetLocalizationDictionary().ToResource();
}
}
}

View File

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Sonarr.Http.REST;
namespace Sonarr.Api.V3.Localization
{
public class LocalizationResourceSerializer : JsonConverter<Dictionary<string, string>>
{
public override Dictionary<string, string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
throw new NotImplementedException();
}
public override void Write(Utf8JsonWriter writer, Dictionary<string, string> dictionary, JsonSerializerOptions options)
{
writer.WriteStartObject();
foreach (var (key, value) in dictionary)
{
var propertyName = key;
writer.WritePropertyName(propertyName);
writer.WriteStringValue(value);
}
writer.WriteEndObject();
}
}
public class LocalizationResource : RestResource
{
[JsonConverter(typeof(LocalizationResourceSerializer))]
public Dictionary<string, string> Strings { get; set; }
}
public static class LocalizationResourceMapper
{
public static LocalizationResource ToResource(this Dictionary<string, string> localization)
{
if (localization == null)
{
return null;
}
return new LocalizationResource
{
Strings = localization,
};
}
}
}