Blog Series: Episerver Settings Tab Improvements – Language Support

Blog Series: Episerver Settings Tab Improvements – Language Support

In one of my latest blog post I explained how you can configure your global settings so they can be located in their own assets tab in the Episerver CMS instead of a home page or a settings page. The implementation idea was explained in a Episerver Dev Happy Hour meeting and now it is part of the Episerver Foundation and Episerver Foundation CMS solutions. However, after we implemented it for a client which has a multi site, multi language solution, we found that the current code was not able to handle multiple languages. So, in this post we are going to show how can we improve the current implementation to handle them.

For this implementation we will only modify the original SettingService.cs class which is part of the Episerver Foundation CMS solution. The changes required to support multiple languages are the following:

First, we will modify the ISettingsService interface to allow languages in two of the methods: GetSiteSettings and UpdateSettings and also we will modify the signature for the concurrent dictionary used in the class from a Guid, Dictionary<Type, Object> to a string, Dictionary<Type, Object>

 T GetSiteSettings<T>(Guid? siteId = null, string language = null);
 void UpdateSettings(Guid siteId, string language,, IContent content);
 ConcurrentDictionary<string, Dictionary<Type, object>> SiteSettings { get; }

We then modify the concurrent dictionary in the Setting Service class implementation

public ConcurrentDictionary<string, Dictionary<Type, object>> SiteSettings { get; } = new ConcurrentDictionary<string, Dictionary<Type, object>>();

After that, we will add another dependency to the SettingService constructor, in this case to the LanguageBranchRepository class.

private readonly ILanguageBranchRepository _languageBranchRepository;

public SettingsService(
            IContentRepository contentRepository,
            ContentRootService contentRootService,
            ITypeScannerLookup typeScannerLookup,
            IContentTypeRepository contentTypeRepository,
            IContentEvents contentEvents,
            ISiteDefinitionEvents siteDefinitionEvents,
            ISiteDefinitionRepository siteDefinitionRepository,
            ISiteDefinitionResolver siteDefinitionResolver,
            ILanguageBranchRepository languageBranchRepository,
            ServiceAccessor<HttpContextBase> httpContext)
        {
            _contentRepository = contentRepository;
            _contentRootService = contentRootService;
            _typeScannerLookup = typeScannerLookup;
            _contentTypeRepository = contentTypeRepository;
            _contentEvents = contentEvents;
            _siteDefinitionEvents = siteDefinitionEvents;
            _siteDefinitionRepository = siteDefinitionRepository;
            _siteDefinitionResolver = siteDefinitionResolver;
            _httpContext = httpContext;
            _languageBranchRepository = languageBranchRepository;
        }

With that dependency in place, we can add two new private methods. The first method will get all available languages for the solution and it is properly called GetAvailableLanguages

        private List<LanguageBranch> GetAvailableLanguages()
        {
            return _languageBranchRepository.ListEnabled()?.ToList() ?? new List<LanguageBranch>();
        }

The second method will get the current language based on the Current Thread (In case that the current thread is not returning the proper language, we can use the Content Language Preferred Culture property), but if it cannot find it (Jobs calls to settings) it will use the first language it finds as default. The method is called GetCurrentLanguage

        private LanguageBranch GetCurrentLanguage()
        {
            var availableLanguages = GetAvailableLanguages();
            var defaultLanguage = availableLanguages.FirstOrDefault();

            // If language is not detected properly by the current thread property use the content language preferred culture instead
            //var currentLanguage = availableLanguages.Find(x => x.LanguageID == Thread.CurrentThread.CurrentUICulture.ToString());
            var currentLanguage = availableLanguages.Find(x => x.LanguageID == ContentLanguage.PreferredCulture.Name);
            return currentLanguage ?? defaultLanguage;
        }

Then, we will modify the two methods that we changed the signature in the interface ISettingsService. The first method GetSiteSettings, will allow two parameters, the site id and the language. If the language parameter is not passed, it will use the current language. Using the site id plus the language it will generate a new key as a string and add it to the concurrent dictionary

        public T GetSiteSettings<T>(Guid? siteId = null, string language = null)
        {
            if (!siteId.HasValue)
            {
                siteId = ResolveSiteId();
                if (siteId == Guid.Empty)
                {
                    return default;
                }
            }

            if (string.IsNullOrEmpty(language))
            {
                language = GetCurrentLanguage().LanguageID.ToLower();
                if (string.IsNullOrEmpty(language))
                {
                    return default;
                }
            }

            try
            {
                var key = siteId.Value + "_" + language;
                if (SiteSettings.TryGetValue(key, out var siteSettings) && siteSettings.TryGetValue(typeof(T), out var setting))
                {
                    return (T)setting;
                }
            }
            catch (KeyNotFoundException keyNotFoundException)
            {
                _log.Error($"[Settings] {keyNotFoundException.Message}", exception: keyNotFoundException);
            }
            catch (ArgumentNullException argumentNullException)
            {
                _log.Error($"[Settings] {argumentNullException.Message}", exception: argumentNullException);
            }

            return default;
        }

The second method UpdateSettings, will follow the same logic as the GetSiteSettings method. Using the site id and the language it will generate a key so it can update the content item in the concurrent dictionary

        public void UpdateSettings(Guid siteId, string language, IContent content)
        {
            var contentType = content.GetOriginalType();
            try
            {
                var key = siteId + "_" + language;
                if (!SiteSettings.ContainsKey(key))
                {
                    SiteSettings[key] = new Dictionary<Type, object>();
                }

                SiteSettings[key][contentType] = content;
            }
            catch (KeyNotFoundException keyNotFoundException)
            {
                _log.Error($"[Settings] {keyNotFoundException.Message}", exception: keyNotFoundException);
            }
            catch (ArgumentNullException argumentNullException)
            {
                _log.Error($"[Settings] {argumentNullException.Message}", exception: argumentNullException);
            }
        }

After these modifications, we will have some compilation errors that we will solve one by one. First, we will modify the UpdateSettings method so it takes into account all available languages in the solution and most important, the get children method will use a language parameter so it can differentiate between different language content items.

        public void UpdateSettings()
        {
            var root = _contentRepository.GetItems(_contentRootService.List(), new LoaderOptions())
                .FirstOrDefault(x => x.ContentGuid == SettingsFolder.SettingsRootGuid);

            if (root == null)
            {
                return;
            }

            GlobalSettingsRoot = root.ContentLink;
            var children = _contentRepository.GetChildren<SettingsFolder>(GlobalSettingsRoot).ToList();
            foreach (var site in _siteDefinitionRepository.List())
            {
                var languages = GetAvailableLanguages();
                var folder = children.Find(x => x.Name.Equals(site.Name, StringComparison.InvariantCultureIgnoreCase));
                if (folder != null)
                {
                    foreach (var language in languages)
                    {
                        foreach (var child in _contentRepository.GetChildren<SettingsBase>(folder.ContentLink,
                            language.Culture))
                        {
                            UpdateSettings(site.Id, language.LanguageID.ToLower(), child);
                        }
                    }

                    CreateSiteFolder(site);
                }
            }
        }

We will also modify one line of code in the CreateSiteFolder method so it take by default the current language

        private void CreateSiteFolder(SiteDefinition siteDefinition)
        {
            var folder = _contentRepository.GetDefault<SettingsFolder>(GlobalSettingsRoot);
            folder.Name = siteDefinition.Name;
            var reference = _contentRepository.Save(folder, SaveAction.Publish, AccessLevel.NoAccess);

            var settingsModelTypes = _typeScannerLookup.AllTypes
                .Where(t => t.GetCustomAttributes(typeof(SettingsContentTypeAttribute), false).Length > 0);

            foreach (var settingsType in settingsModelTypes)
            {
                if (!(settingsType.GetCustomAttributes(typeof(SettingsContentTypeAttribute), false)
                    .FirstOrDefault() is SettingsContentTypeAttribute attribute))
                {
                    continue;
                }

                var contentType = _contentTypeRepository.Load(settingsType);
                var newSettings = _contentRepository.GetDefault<IContent>(reference, contentType.ID);
                newSettings.Name = attribute.SettingsName;
                _contentRepository.Save(newSettings, SaveAction.Publish, AccessLevel.NoAccess);
                UpdateSettings(siteDefinition.Id, GetCurrentLanguage().LanguageID.ToLower(), newSettings);
            }
        }

Finally, we will modify the PublishedContent method so it gets the published item language and pass it to update the concurrent dictionary with the proper language key

        private void PublishedContent(object sender, ContentEventArgs e)
        {
            if (!(e?.Content is SettingsBase setting))
            {
                return;
            }

            var parent = _contentRepository.Get<IContent>(e.Content.ParentLink);
            var site = _siteDefinitionRepository.Get(parent.Name);
            var language = setting.Language.Name.ToLower();

            var id = site?.Id;
            if (id == null || id == Guid.Empty)
            {
                return;
            }
            UpdateSettings(id.Value, language,  e.Content);
        }

After all those changes the final implementation of the Settings Service class will look like this

using EPiServer;
using EPiServer.Core;
using EPiServer.DataAbstraction;
using EPiServer.Logging;
using EPiServer.Security;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using EPiServer.DataAccess;
using EPiServer.Framework.TypeScanner;
using System.Threading;

namespace Foundation.Cms.Settings
{
    public interface ISettingsService
    {
        ContentReference GlobalSettingsRoot { get; set; }
        ConcurrentDictionary<string, Dictionary<Type, object>> SiteSettings { get; }
        T GetSiteSettings<T>(Guid? siteId = null, string language = null);
        void InitializeSettings();
        void UnInitializeSettings();
        void UpdateSettings(Guid siteId, string language, IContent content);
        void UpdateSettings();
    }

    public static class ISettingsServiceExtensions
    {
        public static T GetSiteSettingsOrThrow<T>(this ISettingsService settingsService,
            Func<T, bool> shouldThrow,
            string message) where T : SettingsBase
        {
            var settings = settingsService.GetSiteSettings<T>();
            if (settings == null || (shouldThrow?.Invoke(settings) ?? false))
            {
                throw new InvalidOperationException(message);
            }

            return settings;
        }

        public static bool TryGetSiteSettings<T>(this ISettingsService settingsService, out T value) where T : SettingsBase
        {
            value = settingsService.GetSiteSettings<T>();
            return value != null;
        }
    }

    public class SettingsService : ISettingsService
    {
        private readonly IContentRepository _contentRepository;
        private readonly ContentRootService _contentRootService;
        private readonly IContentTypeRepository _contentTypeRepository;
        private readonly ILogger _log = LogManager.GetLogger();
        private readonly ITypeScannerLookup _typeScannerLookup;
        private readonly IContentEvents _contentEvents;
        private readonly ISiteDefinitionEvents _siteDefinitionEvents;
        private readonly ISiteDefinitionRepository _siteDefinitionRepository;
        private readonly ISiteDefinitionResolver _siteDefinitionResolver;
        private readonly ServiceAccessor<HttpContextBase> _httpContext;
        private readonly ILanguageBranchRepository _languageBranchRepository;

        public SettingsService(
            IContentRepository contentRepository,
            ContentRootService contentRootService,
            ITypeScannerLookup typeScannerLookup,
            IContentTypeRepository contentTypeRepository,
            IContentEvents contentEvents,
            ISiteDefinitionEvents siteDefinitionEvents,
            ISiteDefinitionRepository siteDefinitionRepository,
            ISiteDefinitionResolver siteDefinitionResolver,
            ILanguageBranchRepository languageBranchRepository,
            ServiceAccessor<HttpContextBase> httpContext)
        {
            _contentRepository = contentRepository;
            _contentRootService = contentRootService;
            _typeScannerLookup = typeScannerLookup;
            _contentTypeRepository = contentTypeRepository;
            _contentEvents = contentEvents;
            _siteDefinitionEvents = siteDefinitionEvents;
            _siteDefinitionRepository = siteDefinitionRepository;
            _siteDefinitionResolver = siteDefinitionResolver;
            _httpContext = httpContext;
            _languageBranchRepository = languageBranchRepository;
        }

        public ConcurrentDictionary<string, Dictionary<Type, object>> SiteSettings { get; } = new ConcurrentDictionary<string, Dictionary<Type, object>>();

        public ContentReference GlobalSettingsRoot { get; set; }

        public T GetSiteSettings<T>(Guid? siteId = null, string language = null)
        {
            if (!siteId.HasValue)
            {
                siteId = ResolveSiteId();
                if (siteId == Guid.Empty)
                {
                    return default;
                }
            }

            if (string.IsNullOrEmpty(language))
            {
                language = GetCurrentLanguage().LanguageID.ToLower();
                if (string.IsNullOrEmpty(language))
                {
                    return default;
                }
            }

            try
            {
                var key = siteId.Value + "_" + language;
                if (SiteSettings.TryGetValue(key, out var siteSettings) && siteSettings.TryGetValue(typeof(T), out var setting))
                {
                    return (T)setting;
                }
            }
            catch (KeyNotFoundException keyNotFoundException)
            {
                _log.Error($"[Settings] {keyNotFoundException.Message}", exception: keyNotFoundException);
            }
            catch (ArgumentNullException argumentNullException)
            {
                _log.Error($"[Settings] {argumentNullException.Message}", exception: argumentNullException);
            }

            return default;
        }

        public void UpdateSettings(Guid siteId, string language, IContent content)
        {
            var contentType = content.GetOriginalType();
            try
            {
                var key = siteId + "_" + language;
                if (!SiteSettings.ContainsKey(key))
                {
                    SiteSettings[key] = new Dictionary<Type, object>();
                }

                SiteSettings[key][contentType] = content;
            }
            catch (KeyNotFoundException keyNotFoundException)
            {
                _log.Error($"[Settings] {keyNotFoundException.Message}", exception: keyNotFoundException);
            }
            catch (ArgumentNullException argumentNullException)
            {
                _log.Error($"[Settings] {argumentNullException.Message}", exception: argumentNullException);
            }
        }

        public void InitializeSettings()
        {
            try
            {
                RegisterContentRoots();
            }
            catch (NotSupportedException notSupportedException)
            {
                _log.Error($"[Settings] {notSupportedException.Message}", exception: notSupportedException);
                throw;
            }

            _contentEvents.PublishedContent += PublishedContent;
            _siteDefinitionEvents.SiteCreated += SiteCreated;
            _siteDefinitionEvents.SiteUpdated += SiteUpdated;
            _siteDefinitionEvents.SiteDeleted += SiteDeleted;
        }

        public void UnInitializeSettings()
        {
            _contentEvents.PublishedContent -= PublishedContent;
            _siteDefinitionEvents.SiteCreated -= SiteCreated;
            _siteDefinitionEvents.SiteUpdated -= SiteUpdated;
            _siteDefinitionEvents.SiteDeleted -= SiteDeleted;
        }

        public void UpdateSettings()
        {
            var root = _contentRepository.GetItems(_contentRootService.List(), new LoaderOptions())
                .FirstOrDefault(x => x.ContentGuid == SettingsFolder.SettingsRootGuid);

            if (root == null)
            {
                return;
            }

            GlobalSettingsRoot = root.ContentLink;
            var children = _contentRepository.GetChildren<SettingsFolder>(GlobalSettingsRoot).ToList();
            foreach (var site in _siteDefinitionRepository.List())
            {
                var languages = GetAvailableLanguages();
                var folder = children.Find(x => x.Name.Equals(site.Name, StringComparison.InvariantCultureIgnoreCase));
                if (folder != null)
                {
                    foreach (var language in languages)
                    {
                        foreach (var child in _contentRepository.GetChildren<SettingsBase>(folder.ContentLink,
                            language.Culture))
                        {
                            UpdateSettings(site.Id, language.LanguageID.ToLower(), child);
                        }
                    }

                    CreateSiteFolder(site);
                }
            }
        }

        private void RegisterContentRoots()
        {
            var registeredRoots = _contentRepository.GetItems(_contentRootService.List(), new LoaderOptions());
            var settingsRootRegistered = registeredRoots.Any(x => x.ContentGuid == SettingsFolder.SettingsRootGuid && x.Name.Equals(SettingsFolder.SettingsRootName));

            if (!settingsRootRegistered)
            {
                _contentRootService.Register<SettingsFolder>(SettingsFolder.SettingsRootName, SettingsFolder.SettingsRootGuid, ContentReference.RootPage);
            }

            UpdateSettings();
        }

        private void CreateSiteFolder(SiteDefinition siteDefinition)
        {
            var folder = _contentRepository.GetDefault<SettingsFolder>(GlobalSettingsRoot);
            folder.Name = siteDefinition.Name;
            var reference = _contentRepository.Save(folder, SaveAction.Publish, AccessLevel.NoAccess);

            var settingsModelTypes = _typeScannerLookup.AllTypes
                .Where(t => t.GetCustomAttributes(typeof(SettingsContentTypeAttribute), false).Length > 0);

            foreach (var settingsType in settingsModelTypes)
            {
                if (!(settingsType.GetCustomAttributes(typeof(SettingsContentTypeAttribute), false)
                    .FirstOrDefault() is SettingsContentTypeAttribute attribute))
                {
                    continue;
                }

                var contentType = _contentTypeRepository.Load(settingsType);
                var newSettings = _contentRepository.GetDefault<IContent>(reference, contentType.ID);
                newSettings.Name = attribute.SettingsName;
                _contentRepository.Save(newSettings, SaveAction.Publish, AccessLevel.NoAccess);
                UpdateSettings(siteDefinition.Id, GetCurrentLanguage().LanguageID.ToLower(), newSettings);
            }
        }

        private void SiteCreated(object sender, SiteDefinitionEventArgs e)
        {
            if (_contentRepository.GetChildren<SettingsFolder>(GlobalSettingsRoot)
                .Any(x => x.Name.Equals(e.Site.Name, StringComparison.InvariantCultureIgnoreCase)))
            {
                return;
            }

            CreateSiteFolder(e.Site);
        }

        private void SiteDeleted(object sender, SiteDefinitionEventArgs e)
        {
            var folder = _contentRepository.GetChildren<SettingsFolder>(GlobalSettingsRoot)
                .FirstOrDefault(x => x.Name.Equals(e.Site.Name, StringComparison.InvariantCultureIgnoreCase));

            if (folder == null)
            {
                return;
            }

            _contentRepository.Delete(folder.ContentLink, true, AccessLevel.NoAccess);
        }

        private void SiteUpdated(object sender, SiteDefinitionEventArgs e)
        {
            if (e is SiteDefinitionUpdatedEventArgs updatedArgs)
            {
                var prevSite = updatedArgs.PreviousSite;
                var updatedSite = updatedArgs.Site;
                var settingsRoot = GlobalSettingsRoot;
                if (_contentRepository.GetChildren<IContent>(settingsRoot)
                    .FirstOrDefault(x => x.Name.Equals(prevSite.Name, StringComparison.InvariantCultureIgnoreCase)) is ContentFolder currentSettingsFolder)
                {
                    var cloneFolder = currentSettingsFolder.CreateWritableClone();
                    cloneFolder.Name = updatedSite.Name;
                    _contentRepository.Save(cloneFolder);
                    return;
                }
            }


            CreateSiteFolder(e.Site);
        }

        private void PublishedContent(object sender, ContentEventArgs e)
        {
            if (!(e?.Content is SettingsBase setting))
            {
                return;
            }

            var parent = _contentRepository.Get<IContent>(e.Content.ParentLink);
            var site = _siteDefinitionRepository.Get(parent.Name);
            var language = setting.Language.Name.ToLower();

            var id = site?.Id;
            if (id == null || id == Guid.Empty)
            {
                return;
            }
            UpdateSettings(id.Value, language,  e.Content);
        }

        private List<LanguageBranch> GetAvailableLanguages()
        {
            return _languageBranchRepository.ListEnabled()?.ToList() ?? new List<LanguageBranch>();
        }

        private LanguageBranch GetCurrentLanguage()
        {
            var availableLanguages = GetAvailableLanguages();
            var defaultLanguage = availableLanguages.FirstOrDefault();

            // If language is not detected properly by the current thread property use the content language preferred culture instead
            //var currentLanguage = availableLanguages.Find(x => x.LanguageID == Thread.CurrentThread.CurrentUICulture.ToString());
            var currentLanguage = availableLanguages.Find(x => x.LanguageID == ContentLanguage.PreferredCulture.Name);
            return currentLanguage ?? defaultLanguage;
        }

        private Guid ResolveSiteId()
        {
            var request = _httpContext()?.Request;
            if (request == null)
            {
                return Guid.Empty;
            }
            var site = _siteDefinitionResolver.Get(request);
            return site?.Id ?? Guid.Empty;
        }
    }
}

And that is it. Now, your site settings implementation will be able to handle languages without problems. If you have any question let me know. I hope it will help someone and as always keep learning !!!

Written by:

Jorge Cardenas

Developer with several years of experience who is passionate about technology and how to solve problems through it.

View All Posts

3 COMMENTS

Leave a Reply