diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index aaeee6715..6daf4e4c9 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -6,7 +6,7 @@ import { createSelector } from 'reselect'; import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { fetchArtist } from 'Store/Actions/artistActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; -import { fetchImportLists, fetchMetadataProfiles, fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; +import { fetchImportLists, fetchLanguages, fetchMetadataProfiles, fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchTags } from 'Store/Actions/tagActions'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; @@ -46,6 +46,7 @@ const selectIsPopulated = createSelector( (state) => state.customFilters.isPopulated, (state) => state.tags.isPopulated, (state) => state.settings.ui.isPopulated, + (state) => state.settings.languages.isPopulated, (state) => state.settings.qualityProfiles.isPopulated, (state) => state.settings.metadataProfiles.isPopulated, (state) => state.settings.importLists.isPopulated, @@ -54,6 +55,7 @@ const selectIsPopulated = createSelector( customFiltersIsPopulated, tagsIsPopulated, uiSettingsIsPopulated, + languagesIsPopulated, qualityProfilesIsPopulated, metadataProfilesIsPopulated, importListsIsPopulated, @@ -63,6 +65,7 @@ const selectIsPopulated = createSelector( customFiltersIsPopulated && tagsIsPopulated && uiSettingsIsPopulated && + languagesIsPopulated && qualityProfilesIsPopulated && metadataProfilesIsPopulated && importListsIsPopulated && @@ -75,6 +78,7 @@ const selectErrors = createSelector( (state) => state.customFilters.error, (state) => state.tags.error, (state) => state.settings.ui.error, + (state) => state.settings.languages.error, (state) => state.settings.qualityProfiles.error, (state) => state.settings.metadataProfiles.error, (state) => state.settings.importLists.error, @@ -83,6 +87,7 @@ const selectErrors = createSelector( customFiltersError, tagsError, uiSettingsError, + languagesError, qualityProfilesError, metadataProfilesError, importListsError, @@ -92,6 +97,7 @@ const selectErrors = createSelector( customFiltersError || tagsError || uiSettingsError || + languagesError || qualityProfilesError || metadataProfilesError || importListsError || @@ -103,6 +109,7 @@ const selectErrors = createSelector( customFiltersError, tagsError, uiSettingsError, + languagesError, qualityProfilesError, metadataProfilesError, importListsError, @@ -147,6 +154,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchTags() { dispatch(fetchTags()); }, + dispatchFetchLanguages() { + dispatch(fetchLanguages()); + }, dispatchFetchQualityProfiles() { dispatch(fetchQualityProfiles()); }, @@ -189,6 +199,7 @@ class PageConnector extends Component { this.props.dispatchFetchArtist(); this.props.dispatchFetchCustomFilters(); this.props.dispatchFetchTags(); + this.props.dispatchFetchLanguages(); this.props.dispatchFetchQualityProfiles(); this.props.dispatchFetchMetadataProfiles(); this.props.dispatchFetchImportLists(); @@ -213,6 +224,7 @@ class PageConnector extends Component { hasError, dispatchFetchArtist, dispatchFetchTags, + dispatchFetchLanguages, dispatchFetchQualityProfiles, dispatchFetchMetadataProfiles, dispatchFetchImportLists, @@ -252,6 +264,7 @@ PageConnector.propTypes = { dispatchFetchArtist: PropTypes.func.isRequired, dispatchFetchCustomFilters: PropTypes.func.isRequired, dispatchFetchTags: PropTypes.func.isRequired, + dispatchFetchLanguages: PropTypes.func.isRequired, dispatchFetchQualityProfiles: PropTypes.func.isRequired, dispatchFetchMetadataProfiles: PropTypes.func.isRequired, dispatchFetchImportLists: PropTypes.func.isRequired, diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js index fa722fe17..83aa595a2 100644 --- a/frontend/src/Settings/UI/UISettings.js +++ b/frontend/src/Settings/UI/UISettings.js @@ -10,6 +10,7 @@ import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import { inputTypes } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import translate from 'Utilities/String/translate'; import styles from './UISettings.css'; export const firstDayOfWeekOptions = [ @@ -56,9 +57,12 @@ class UISettings extends Component { hasSettings, onInputChange, onSavePress, + languages, ...otherProps } = this.props; + const uiLanguages = languages.filter((item) => item.value !== 'Original'); + return ( +
+ + {translate('UILanguage')} + + +
} @@ -235,6 +253,7 @@ UISettings.propTypes = { settings: PropTypes.object.isRequired, hasSettings: PropTypes.bool.isRequired, onSavePress: PropTypes.func.isRequired, + languages: PropTypes.arrayOf(PropTypes.object).isRequired, onInputChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/UI/UISettingsConnector.js b/frontend/src/Settings/UI/UISettingsConnector.js index d507447e2..31d41f9e0 100644 --- a/frontend/src/Settings/UI/UISettingsConnector.js +++ b/frontend/src/Settings/UI/UISettingsConnector.js @@ -9,13 +9,38 @@ import UISettings from './UISettings'; const SECTION = 'ui'; +function createLanguagesSelector() { + return createSelector( + (state) => state.settings.languages, + (languages) => { + const items = languages.items; + const filterItems = ['Any', 'Unknown']; + + if (!items) { + return []; + } + + const newItems = items.filter((lang) => !filterItems.includes(lang.name)).map((item) => { + return { + key: item.id, + value: item.name + }; + }); + + return newItems; + } + ); +} + function createMapStateToProps() { return createSelector( (state) => state.settings.advancedSettings, createSettingsSectionSelector(SECTION), - (advancedSettings, sectionSettings) => { + createLanguagesSelector(), + (advancedSettings, sectionSettings, languages) => { return { advancedSettings, + languages, ...sectionSettings }; } diff --git a/frontend/src/Store/Actions/Settings/languages.js b/frontend/src/Store/Actions/Settings/languages.js new file mode 100644 index 000000000..a0b62fc49 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/languages.js @@ -0,0 +1,48 @@ +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import { createThunk } from 'Store/thunks'; + +// +// Variables + +const section = 'settings.languages'; + +// +// Actions Types + +export const FETCH_LANGUAGES = 'settings/languages/fetchLanguages'; + +// +// Action Creators + +export const fetchLanguages = createThunk(FETCH_LANGUAGES); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_LANGUAGES]: createFetchHandler(section, '/language') + }, + + // + // Reducers + + reducers: { + + } + +}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 7d7e682cd..3e28acaff 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -9,6 +9,7 @@ import importListExclusions from './Settings/importListExclusions'; import importLists from './Settings/importLists'; import indexerOptions from './Settings/indexerOptions'; import indexers from './Settings/indexers'; +import languages from './Settings/languages'; import mediaManagement from './Settings/mediaManagement'; import metadata from './Settings/metadata'; import metadataProfiles from './Settings/metadataProfiles'; @@ -31,6 +32,7 @@ export * from './Settings/importLists'; export * from './Settings/importListExclusions'; export * from './Settings/indexerOptions'; export * from './Settings/indexers'; +export * from './Settings/languages'; export * from './Settings/metadataProfiles'; export * from './Settings/mediaManagement'; export * from './Settings/metadata'; @@ -64,6 +66,7 @@ export const defaultState = { indexers: indexers.defaultState, importLists: importLists.defaultState, importListExclusions: importListExclusions.defaultState, + languages: languages.defaultState, metadataProfiles: metadataProfiles.defaultState, mediaManagement: mediaManagement.defaultState, metadata: metadata.defaultState, @@ -105,6 +108,7 @@ export const actionHandlers = handleThunks({ ...indexers.actionHandlers, ...importLists.actionHandlers, ...importListExclusions.actionHandlers, + ...languages.actionHandlers, ...metadataProfiles.actionHandlers, ...mediaManagement.actionHandlers, ...metadata.actionHandlers, @@ -137,6 +141,7 @@ export const reducers = createHandleActions({ ...indexers.reducers, ...importLists.reducers, ...importListExclusions.reducers, + ...languages.reducers, ...metadataProfiles.reducers, ...mediaManagement.reducers, ...metadata.reducers, diff --git a/frontend/src/Utilities/String/translate.js b/frontend/src/Utilities/String/translate.js new file mode 100644 index 000000000..bb05068ae --- /dev/null +++ b/frontend/src/Utilities/String/translate.js @@ -0,0 +1,34 @@ +import $ from 'jquery'; + +function getTranslations() { + let localization = null; + const ajaxOptions = { + async: false, + type: 'GET', + global: false, + dataType: 'json', + url: `${window.Lidarr.apiRoot}/localization`, + success: function(data) { + localization = data.Strings; + } + }; + + ajaxOptions.headers = ajaxOptions.headers || {}; + ajaxOptions.headers['X-Api-Key'] = window.Lidarr.apiKey; + + $.ajax(ajaxOptions); + return localization; +} + +const translations = getTranslations(); + +export default function translate(key, args = '') { + if (args) { + const translatedKey = translate(key); + return translatedKey.replace(/\{(\d+)\}/g, (match, index) => { + return args[index]; + }); + } + + return translations[key] || key; +} diff --git a/src/Lidarr.Api.V1/Config/UiConfigResource.cs b/src/Lidarr.Api.V1/Config/UiConfigResource.cs index 4593a94cc..3e6883dda 100644 --- a/src/Lidarr.Api.V1/Config/UiConfigResource.cs +++ b/src/Lidarr.Api.V1/Config/UiConfigResource.cs @@ -16,6 +16,7 @@ namespace Lidarr.Api.V1.Config public bool ShowRelativeDates { get; set; } public bool EnableColorImpairedMode { get; set; } + public int UILanguage { get; set; } public bool ExpandAlbumByDefault { get; set; } public bool ExpandSingleByDefault { get; set; } @@ -39,6 +40,7 @@ namespace Lidarr.Api.V1.Config ShowRelativeDates = model.ShowRelativeDates, EnableColorImpairedMode = model.EnableColorImpairedMode, + UILanguage = model.UILanguage, ExpandAlbumByDefault = model.ExpandAlbumByDefault, ExpandSingleByDefault = model.ExpandSingleByDefault, diff --git a/src/Lidarr.Api.V1/Languages/LanguageController.cs b/src/Lidarr.Api.V1/Languages/LanguageController.cs new file mode 100644 index 000000000..c24102510 --- /dev/null +++ b/src/Lidarr.Api.V1/Languages/LanguageController.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using Lidarr.Http; +using Lidarr.Http.REST; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Languages; + +namespace Lidarr.Api.V1.Languages +{ + [V1ApiController] + public class LanguageController : RestController + { + public override LanguageResource GetResourceById(int id) + { + var language = (Language)id; + + return new LanguageResource + { + Id = (int)language, + Name = language.ToString() + }; + } + + [HttpGet] + public List GetAll() + { + return Language.All.Select(l => new LanguageResource + { + Id = (int)l, + Name = l.ToString() + }) + .OrderBy(l => l.Name) + .ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Languages/LanguageResource.cs b/src/Lidarr.Api.V1/Languages/LanguageResource.cs new file mode 100644 index 000000000..7059813fe --- /dev/null +++ b/src/Lidarr.Api.V1/Languages/LanguageResource.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Languages +{ + public class LanguageResource : RestResource + { + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public new int Id { get; set; } + public string Name { get; set; } + public string NameLower => Name.ToLowerInvariant(); + } +} diff --git a/src/Lidarr.Api.V1/Localization/LocalizationController.cs b/src/Lidarr.Api.V1/Localization/LocalizationController.cs new file mode 100644 index 000000000..fa09242d7 --- /dev/null +++ b/src/Lidarr.Api.V1/Localization/LocalizationController.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using Lidarr.Http; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Localization; + +namespace Lidarr.Api.V1.Localization +{ + [V1ApiController] + public class LocalizationController : Controller + { + private readonly ILocalizationService _localizationService; + private readonly JsonSerializerOptions _serializerSettings; + + public LocalizationController(ILocalizationService localizationService) + { + _localizationService = localizationService; + _serializerSettings = STJson.GetSerializerSettings(); + _serializerSettings.DictionaryKeyPolicy = null; + _serializerSettings.PropertyNamingPolicy = null; + } + + [HttpGet] + public string GetLocalizationDictionary() + { + return JsonSerializer.Serialize(_localizationService.GetLocalizationDictionary().ToResource(), _serializerSettings); + } + } +} diff --git a/src/Lidarr.Api.V1/Localization/LocalizationResource.cs b/src/Lidarr.Api.V1/Localization/LocalizationResource.cs new file mode 100644 index 000000000..e5c2d9844 --- /dev/null +++ b/src/Lidarr.Api.V1/Localization/LocalizationResource.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Localization +{ + public class LocalizationResource : RestResource + { + public Dictionary Strings { get; set; } + } + + public static class LocalizationResourceMapper + { + public static LocalizationResource ToResource(this Dictionary localization) + { + if (localization == null) + { + return null; + } + + return new LocalizationResource + { + Strings = localization, + }; + } + } +} diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index a376c875a..e4ef277cc 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -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.Messaging.Events; using NzbDrone.Core.Qualities; @@ -339,6 +340,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 ExpandAlbumByDefault { get { return GetValueBoolean("ExpandAlbumByDefault", false); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index a20aeb390..de45d1453 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -60,6 +60,7 @@ namespace NzbDrone.Core.Configuration string TimeFormat { get; set; } bool ShowRelativeDates { get; set; } bool EnableColorImpairedMode { get; set; } + int UILanguage { get; set; } bool ExpandAlbumByDefault { get; set; } bool ExpandSingleByDefault { get; set; } diff --git a/src/NzbDrone.Core/Languages/Language.cs b/src/NzbDrone.Core/Languages/Language.cs new file mode 100644 index 000000000..1a634e6d6 --- /dev/null +++ b/src/NzbDrone.Core/Languages/Language.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Languages +{ + public class Language : IEmbeddedDocument, IEquatable + { + public int Id { get; set; } + public string Name { get; set; } + + public Language() + { + } + + private Language(int id, string name) + { + Id = id; + Name = name; + } + + public override string ToString() + { + return Name; + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } + + public bool Equals(Language other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Id.Equals(other.Id); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + return Equals(obj as Language); + } + + public static bool operator ==(Language left, Language right) + { + return Equals(left, right); + } + + public static bool operator !=(Language left, Language right) + { + return !Equals(left, right); + } + + public static Language Unknown => new Language(0, "Unknown"); + public static Language English => new Language(1, "English"); + public static Language French => new Language(2, "French"); + public static Language Spanish => new Language(3, "Spanish"); + public static Language German => new Language(4, "German"); + public static Language Italian => new Language(5, "Italian"); + public static Language Danish => new Language(6, "Danish"); + public static Language Dutch => new Language(7, "Dutch"); + public static Language Japanese => new Language(8, "Japanese"); + public static Language Icelandic => new Language(9, "Icelandic"); + public static Language Chinese => new Language(10, "Chinese"); + public static Language Russian => new Language(11, "Russian"); + public static Language Polish => new Language(12, "Polish"); + public static Language Vietnamese => new Language(13, "Vietnamese"); + public static Language Swedish => new Language(14, "Swedish"); + public static Language Norwegian => new Language(15, "Norwegian"); + public static Language Finnish => new Language(16, "Finnish"); + public static Language Turkish => new Language(17, "Turkish"); + public static Language Portuguese => new Language(18, "Portuguese"); + public static Language Flemish => new Language(19, "Flemish"); + public static Language Greek => new Language(20, "Greek"); + public static Language Korean => new Language(21, "Korean"); + public static Language Hungarian => new Language(22, "Hungarian"); + public static Language Hebrew => new Language(23, "Hebrew"); + public static Language Lithuanian => new Language(24, "Lithuanian"); + public static Language Czech => new Language(25, "Czech"); + public static Language Hindi => new Language(26, "Hindi"); + public static Language Romanian => new Language(27, "Romanian"); + public static Language Thai => new Language(28, "Thai"); + public static Language Bulgarian => new Language(29, "Bulgarian"); + public static Language PortugueseBR => new Language(30, "Portuguese (Brazil)"); + public static Language Arabic => new Language(31, "Arabic"); + public static Language Any => new Language(-1, "Any"); + public static Language Original => new Language(-2, "Original"); + + public static List All + { + get + { + return new List + { + Unknown, + English, + French, + Spanish, + German, + Italian, + Danish, + Dutch, + Japanese, + Icelandic, + Chinese, + Russian, + Polish, + Vietnamese, + Swedish, + Norwegian, + Finnish, + Turkish, + Portuguese, + Flemish, + Greek, + Korean, + Hungarian, + Hebrew, + Lithuanian, + Czech, + Romanian, + Hindi, + Thai, + Bulgarian, + PortugueseBR, + Arabic, + Any, + Original + }; + } + } + + public static Language FindById(int id) + { + if (id == 0) + { + return Unknown; + } + + Language language = All.FirstOrDefault(v => v.Id == id); + + if (language == null) + { + throw new ArgumentException("ID does not match a known language", nameof(id)); + } + + return language; + } + + public static explicit operator Language(int id) + { + return FindById(id); + } + + public static explicit operator int(Language language) + { + return language.Id; + } + + public static explicit operator Language(string lang) + { + var language = All.FirstOrDefault(v => v.Name.Equals(lang, StringComparison.InvariantCultureIgnoreCase)); + + if (language == null) + { + throw new ArgumentException("Language does not match a known language", nameof(lang)); + } + + return language; + } + } +} diff --git a/src/NzbDrone.Core/Languages/LanguageExtensions.cs b/src/NzbDrone.Core/Languages/LanguageExtensions.cs new file mode 100644 index 000000000..f9c42b078 --- /dev/null +++ b/src/NzbDrone.Core/Languages/LanguageExtensions.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.Languages +{ + public static class LanguageExtensions + { + public static string ToExtendedString(this IEnumerable languages) + { + return string.Join(", ", languages.Select(l => l.ToString())); + } + } +} diff --git a/src/NzbDrone.Core/Languages/LanguageFieldConverter.cs b/src/NzbDrone.Core/Languages/LanguageFieldConverter.cs new file mode 100644 index 000000000..143503d90 --- /dev/null +++ b/src/NzbDrone.Core/Languages/LanguageFieldConverter.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.Languages +{ + public class LanguageFieldConverter + { + public List GetSelectOptions() + { + return Language.All.ConvertAll(v => new FieldSelectOption { Value = v.Id, Name = v.Name }); + } + } +} diff --git a/src/NzbDrone.Core/Languages/LanguagesComparer.cs b/src/NzbDrone.Core/Languages/LanguagesComparer.cs new file mode 100644 index 000000000..02a9dd3d6 --- /dev/null +++ b/src/NzbDrone.Core/Languages/LanguagesComparer.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.Languages +{ + public class LanguagesComparer : IComparer> + { + public int Compare(List x, List y) + { + if (!x.Any() && !y.Any()) + { + return 0; + } + + if (!x.Any() && y.Any()) + { + return 1; + } + + if (x.Any() && !y.Any()) + { + return -1; + } + + if (x.Count > 1 && y.Count > 1 && x.Count > y.Count) + { + return 1; + } + + if (x.Count > 1 && y.Count > 1 && x.Count < y.Count) + { + return -1; + } + + if (x.Count > 1 && y.Count == 1) + { + return 1; + } + + if (x.Count == 1 && y.Count > 1) + { + return -1; + } + + if (x.Count == 1 && y.Count == 1) + { + return x.First().Name.CompareTo(y.First().Name); + } + + return 0; + } + } +} diff --git a/src/NzbDrone.Core/Languages/RealLanguageFieldConverter.cs b/src/NzbDrone.Core/Languages/RealLanguageFieldConverter.cs new file mode 100644 index 000000000..f5eac7aaf --- /dev/null +++ b/src/NzbDrone.Core/Languages/RealLanguageFieldConverter.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.Languages +{ + public class RealLanguageFieldConverter + { + public List GetSelectOptions() + { + return Language.All + .Where(l => l != Language.Unknown && l != Language.Any) + .ToList() + .ConvertAll(v => new FieldSelectOption { Value = v.Id, Name = v.Name }); + } + } +} diff --git a/src/NzbDrone.Core/Lidarr.Core.csproj b/src/NzbDrone.Core/Lidarr.Core.csproj index 01e71f851..d950fdeac 100644 --- a/src/NzbDrone.Core/Lidarr.Core.csproj +++ b/src/NzbDrone.Core/Lidarr.Core.csproj @@ -28,6 +28,11 @@ + + + PreserveNewest + + Resources\Logo\64.png diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json new file mode 100644 index 000000000..918de0121 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -0,0 +1,4 @@ +{ + "Language": "Language", + "UILanguage": "UI Language" +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Localization/LocalizationService.cs b/src/NzbDrone.Core/Localization/LocalizationService.cs new file mode 100644 index 000000000..66fda0b60 --- /dev/null +++ b/src/NzbDrone.Core/Localization/LocalizationService.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +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 GetLocalizationDictionary(); + string GetLocalizedString(string phrase); + string GetLocalizedString(string phrase, string language); + } + + public class LocalizationService : ILocalizationService, IHandleAsync + { + private const string DefaultCulture = "en"; + + private readonly ICached> _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>(typeof(Dictionary), "localization"); + _logger = logger; + } + + public Dictionary 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 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> GetDictionary(string prefix, string culture, string baseFilename) + { + if (string.IsNullOrEmpty(culture)) + { + throw new ArgumentNullException(nameof(culture)); + } + + var dictionary = new Dictionary(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 dictionary, string resourcePath) + { + if (!File.Exists(resourcePath)) + { + _logger.Error("Missing translation/culture resource: {0}", resourcePath); + return; + } + + using (var fs = File.OpenRead(resourcePath)) + { + if (fs != null) + { + var dict = await JsonSerializer.DeserializeAsync>(fs); + + foreach (var key in dict.Keys) + { + dictionary[key] = dict[key]; + } + } + else + { + _logger.Error("Missing translation/culture resource: {0}", resourcePath); + } + } + } + + 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(); + } + } +} diff --git a/src/NzbDrone.Core/Parser/IsoLanguage.cs b/src/NzbDrone.Core/Parser/IsoLanguage.cs index 2b2dc7198..6f919c572 100644 --- a/src/NzbDrone.Core/Parser/IsoLanguage.cs +++ b/src/NzbDrone.Core/Parser/IsoLanguage.cs @@ -1,14 +1,22 @@ +using NzbDrone.Core.Languages; + namespace NzbDrone.Core.Parser { public class IsoLanguage { public string TwoLetterCode { get; set; } public string ThreeLetterCode { get; set; } + public string CountryCode { get; set; } + public string EnglishName { get; set; } + public Language Language { get; set; } - public IsoLanguage(string twoLetterCode, string threeLetterCode) + public IsoLanguage(string twoLetterCode, string countryCode, string threeLetterCode, string englishName, Language language) { TwoLetterCode = twoLetterCode; ThreeLetterCode = threeLetterCode; + CountryCode = countryCode; + EnglishName = englishName; + Language = language; } } } diff --git a/src/NzbDrone.Core/Parser/IsoLanguages.cs b/src/NzbDrone.Core/Parser/IsoLanguages.cs index 9ef23ead0..b0493dd7b 100644 --- a/src/NzbDrone.Core/Parser/IsoLanguages.cs +++ b/src/NzbDrone.Core/Parser/IsoLanguages.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using NzbDrone.Core.Languages; namespace NzbDrone.Core.Parser { @@ -7,47 +8,74 @@ namespace NzbDrone.Core.Parser { private static readonly HashSet All = new HashSet { - new IsoLanguage("en", "eng"), - new IsoLanguage("fr", "fra"), - new IsoLanguage("es", "spa"), - new IsoLanguage("de", "deu"), - new IsoLanguage("it", "ita"), - new IsoLanguage("da", "dan"), - new IsoLanguage("nl", "nld"), - new IsoLanguage("ja", "jpn"), - new IsoLanguage("is", "isl"), - new IsoLanguage("zh", "zho"), - new IsoLanguage("ru", "rus"), - new IsoLanguage("pl", "pol"), - new IsoLanguage("vi", "vie"), - new IsoLanguage("sv", "swe"), - new IsoLanguage("no", "nor"), - new IsoLanguage("nb", "nob"), // Norwegian Bokmål - new IsoLanguage("fi", "fin"), - new IsoLanguage("tr", "tur"), - new IsoLanguage("pt", "por"), - new IsoLanguage("el", "ell"), - new IsoLanguage("ko", "kor"), - new IsoLanguage("hu", "hun"), - new IsoLanguage("he", "heb"), - new IsoLanguage("lt", "lit"), - new IsoLanguage("cs", "ces") + new IsoLanguage("en", "", "eng", "English", Language.English), + new IsoLanguage("fr", "fr", "fra", "French", Language.French), + new IsoLanguage("es", "", "spa", "Spanish", Language.Spanish), + new IsoLanguage("de", "de", "deu", "German", Language.German), + new IsoLanguage("it", "", "ita", "Italian", Language.Italian), + new IsoLanguage("da", "", "dan", "Danish", Language.Danish), + new IsoLanguage("nl", "", "nld", "Dutch", Language.Dutch), + new IsoLanguage("ja", "", "jpn", "Japanese", Language.Japanese), + new IsoLanguage("is", "", "isl", "Icelandic", Language.Icelandic), + new IsoLanguage("zh", "cn", "zho", "Chinese", Language.Chinese), + new IsoLanguage("ru", "", "rus", "Russian", Language.Russian), + new IsoLanguage("pl", "", "pol", "Polish", Language.Polish), + new IsoLanguage("vi", "", "vie", "Vietnamese", Language.Vietnamese), + new IsoLanguage("sv", "", "swe", "Swedish", Language.Swedish), + new IsoLanguage("no", "", "nor", "Norwegian", Language.Norwegian), + new IsoLanguage("nb", "", "nob", "Norwegian Bokmal", Language.Norwegian), + new IsoLanguage("fi", "", "fin", "Finnish", Language.Finnish), + new IsoLanguage("tr", "", "tur", "Turkish", Language.Turkish), + new IsoLanguage("pt", "pt", "por", "Portuguese", Language.Portuguese), + new IsoLanguage("el", "", "ell", "Greek", Language.Greek), + new IsoLanguage("ko", "", "kor", "Korean", Language.Korean), + new IsoLanguage("hu", "", "hun", "Hungarian", Language.Hungarian), + new IsoLanguage("he", "", "heb", "Hebrew", Language.Hebrew), + new IsoLanguage("cs", "", "ces", "Czech", Language.Czech), + new IsoLanguage("hi", "", "hin", "Hindi", Language.Hindi), + new IsoLanguage("th", "", "tha", "Thai", Language.Thai), + new IsoLanguage("bg", "", "bul", "Bulgarian", Language.Bulgarian), + new IsoLanguage("ro", "", "ron", "Romanian", Language.Romanian), + new IsoLanguage("pt", "br", "", "Portuguese (Brazil)", Language.PortugueseBR), + new IsoLanguage("ar", "", "ara", "Arabic", Language.Arabic) }; public static IsoLanguage Find(string isoCode) { - if (isoCode.Length == 2) + var isoArray = isoCode.Split('-'); + + var langCode = isoArray[0].ToLower(); + + if (langCode.Length == 2) { //Lookup ISO639-1 code - return All.SingleOrDefault(l => l.TwoLetterCode == isoCode); + var isoLanguages = All.Where(l => l.TwoLetterCode == langCode).ToList(); + + if (isoArray.Length > 1) + { + isoLanguages = isoLanguages.Any(l => l.CountryCode == isoArray[1].ToLower()) ? + isoLanguages.Where(l => l.CountryCode == isoArray[1].ToLower()).ToList() : isoLanguages.Where(l => string.IsNullOrEmpty(l.CountryCode)).ToList(); + } + + return isoLanguages.FirstOrDefault(); } - else if (isoCode.Length == 3) + else if (langCode.Length == 3) { //Lookup ISO639-2T code - return All.SingleOrDefault(l => l.ThreeLetterCode == isoCode); + return All.FirstOrDefault(l => l.ThreeLetterCode == langCode); } return null; } + + public static IsoLanguage FindByName(string name) + { + return All.FirstOrDefault(l => l.EnglishName == name.Trim()); + } + + public static IsoLanguage Get(Language language) + { + return All.FirstOrDefault(l => l.Language == language); + } } }