Online translation localization provider

  • Sep 20, 2014
  • EPiServer
  • Localization
  • |

When I’m developing a multi language website the same problem occurs every time, the translation of the language XML files. Normally the website design is including one general language and the other languages are hand over later in the development process. For me as a developer I would like to test the different (translated) pages immediately while developing. If I would like to do this I’ve to set up a language XML for each available language in EPiServer and keep this up to date when adding new labels. I’ve found the following work around for this problem.

To automate the process of translation each language XML with (dummy) data I’m using online translation tools. The translated values of these tools are not always perfect, but for developers it is just enough for testing purpose. For this example I’m using the Microsoft translation and Yandex translation tool. I’ve implemented two translation tools for showing how to switch between the tools in this example. Both of the tools are accessible with an API. For using the Yandex API click here for obtaining an API key. You can register here for the Microsoft API.

The example is based on the EPiServer Alloy Tech demo website.

This is how the current flow works with the EPiServer localization provider if a label is translated on a MVC view:

  1. Retrieve translated value of the current culture
  2. Retrieve translated value of the fallback language ( if configured )

This is how the situation for this example works in a sequence diagram.

TranslatorLocalizationProvider Custom created localization provider
FileXmlLocalizationProvider EPiServer localization provider
TranslatorCacheProvider Custom created cache provider
YandexTranslatorProvider Custom create translator provider
<h3>@Html.Translate("/footer/products")</h3>

The above example shows how a label is translated in a MVC view. This helper method will call the FileXmlLocalizationProvider (default configured) to get the text of the current culture according the passed key. If the language XML doesn’t contains the passed key the fallback functionality will return a value depending how it’s configured in the web.config.

In this example I created my own localization provider by inheriting from the FileXmlLocalizationProvider. This provider first checks if text can be retrieved for the passed culture, this will call the base method. If the base method returns nothing then the online translation tool will be called, except if the text can be retrieved from the cache provider. I will get later how the cache provider works. So assuming the cache is also empty the base method is again called this time with the fallback culture. Normally setting up a multi language website one language xml file is always filled by the developer. The returned text will be translated by the online translation tool and inserted in the cache.

public class TranslatorLocalizationProvider : FileXmlLocalizationProvider
    {
        private TranslatorProvider _translatorProvider;
        private ITranslatorCacheProvider _cacheProvider;
     
        public TranslatorProvider TranslatorProvider
        {
            get { return _translatorProvider ?? (_translatorProvider = TranslatorProviderManager.Provider); }
        }
     
        public ITranslatorCacheProvider CacheProvider
        {
            get { return (_cacheProvider ?? (_cacheProvider = new TranslatorCacheProvider(TranslatorProvider.CacheFilePath))); }
        }
     
        /// <summary>
        /// Get string override
        /// </summary>
        /// <param name="originalKey"></param>
        /// <param name="normalizedKey"></param>
        /// <param name="culture"></param>
        /// <returns></returns>
        public override string GetString(string originalKey, string[] normalizedKey, System.Globalization.CultureInfo culture)
        {
            var localizationService = ServiceLocator.Current.GetInstance<LocalizationService>();
     
            var value = base.GetString(originalKey, normalizedKey, culture);
     
            if (string.IsNullOrEmpty(value) && !culture.TwoLetterISOLanguageName.Equals(localizationService.FallbackCulture.TwoLetterISOLanguageName)) // Do online translation
            {
                if (CacheProvider.Contains(culture, originalKey)) // Check if the online translation is already cached in de temp xml file
                {
                    return CacheProvider.Get(culture, originalKey);
                }
                var toBeTranslateText = base.GetString(originalKey, normalizedKey, localizationService.FallbackCulture); // Get the fallback translated text so it can be translated via the online tool
                value = TranslatorProvider.Translate(toBeTranslateText, localizationService.FallbackCulture.TwoLetterISOLanguageName, culture.TwoLetterISOLanguageName); // Do translation
                CacheProvider.Insert(culture, normalizedKey, value); // Insert in the cache
            }
            return value;
        }
    }

So the above code snippet is the functionality for the custom localization provider. The provider will call a TranslatorProvider for the translation of a label. The TranslatorProvider is implemented through the Provider Model Design Pattern. Basically this means that the TranslatorProvider is set up in the same way localization providers of EPiServer or the membership/role providers of .NET. In the example I’ve implemented two translator providers: TranslatorProvider and MicrosoftTranslatorProvider. Both providers must implement the Translate method. This method accepts three parameters, text to be translated, ‘from’ culture and the ‘to’ culture. This method will call the third party tool for the translation. Below the YandexTranslatorProvider.

public class YandexTranslatorProvider : TranslatorProvider
    {
        private string _apiKey;
        private readonly ILog _logger;
     
        /// <summary>
        /// Public constructor
        /// </summary>
        public YandexTranslatorProvider()
        {
            _logger = LogManager.GetLogger(typeof (YandexTranslatorProvider));
        }
     
        public override void Initialize(string name, System.Collections.Specialized.NameValueCollection config)
        {
            base.Initialize(name, config);
     
            _apiKey = config["apiKey"];
        }
     
     
        /// <summary>
        /// Translate method
        /// </summary>
        /// <param name="text"></param>
        /// <param name="from"></param>
        /// <param name="to"></param>
        /// <returns></returns>
        public override string Translate(string text, string from, string to)
        {
            var uri = string.Format("https://translate.yandex.net/api/v1.5/tr/translate?key={0}&lang={1}-{2}&text={3}", _apiKey, from, to, text);
            var httpWebRequest = (HttpWebRequest)WebRequest.Create(uri);
     
            WebResponse response = null;
            try
            {
                response = httpWebRequest.GetResponse();
                var reader = new StreamReader(response.GetResponseStream());
     
                 // Read the whole contents and return as a string  
                var value = reader.ReadToEnd();
                var xDoc = XDocument.Parse(value);
                if (xDoc.XPathSelectElement("Translation")
                    .Attribute("code")
                    .Value.Equals("200", StringComparison.InvariantCultureIgnoreCase))
                {
                    return xDoc.XPathSelectElement("Translation/text").Value;
                }
            }
            catch (Exception ex)
            {
                _logger.Error(ex.Message);
            }
            finally
            {
                if (response != null)
                {
                    response.Close();
                }
            }
            return string.Empty;
        }
    }

So the above method will return a translated value for the given culture. The number of requests can be increasing rapidly, so to prevent this I created a cache provider. The cache provider saves the translated value in a XML file. This XML file has the same format as the EPiServer language XML files. The advantage of this that the cached xml file can be used as a ‘EPiServer language XML file’. The example is based on the AlloyTech demo website, if you download and run the website the XML below will be created by the cache provider. This website is configured with Dutch as additional language.

<?xml version="1.0" encoding="utf-8"?>
    <languages>
      <language name="Dutch" id="nl">
        <mainnavigation>
          <search>Zoeken</search>
        </mainnavigation>
        <footer>
          <products>Producten</products>
          <company>Het Bedrijf</company>
          <news>Nieuws </news>
          <customerzone>Klant Zone</customerzone>
          <login>Log in</login>
        </footer>
      </language>
    </languages>

In the web.config the translator providers can be configured as below, again the providers are based on the Provider Model Design Pattern.

<translatorProvider default="yandexTranslator" cacheFilePath="/Resources/">
        <providers>
          <add name="microsoftTranslator" type="Site.Business.Localization.MicrosoftTranslation.MicrosoftTranslatorProvider, Site" clientId="" clientSecret="" />
          <add name="yandexTranslator" type="Site.Business.Localization.YandexTranslation.YandexTranslatorProvider, Site" apiKey="" />
       </providers>
    </translatorProvider>

As you can see in the above code snippet (web.config) you can easily switch between providers by changing the default attribute on the translatorProvider element. On this element also a path can be configured where the cache provider should store a XML file with translated values. You can configure this to the same path where the FileXmlLocalizationProvider needs to search for translated values, in a standard EPiServer installation this path is /Resources/LanguageFiles. When pointing to the same directory EPiServers localization provider will use the ‘cached XML file’ for searching translated values. Below the cache provider.

/// <summary>
    /// Translator cach provider
    /// </summary>
    public class TranslatorCacheProvider : ITranslatorCacheProvider
    {
        private readonly string _xmlPath;
        private readonly IDictionary<string, XDocument> _xDocuments;
        private const string _xmlFilename = "temp_{0}.xml";
        private const string _languageSelectorPreset = "/languages/language/";
     
        /// <summary>
        /// Return xml filename + path
        /// </summary>
        /// <param name="culture"></param>
        /// <returns></returns>
        public string XmlFilenamePath(CultureInfo culture)
        {
            return string.Format("{0}{1}", _xmlPath, string.Format(_xmlFilename, culture.TwoLetterISOLanguageName));
        }
     
        /// <summary>
        /// Public constructor
        /// </summary>
        /// <param name="cacheFilePath"></param>
        public TranslatorCacheProvider(string cacheFilePath)
        {
            if (string.IsNullOrEmpty(cacheFilePath))
            {
                throw new ArgumentException("cacheFilePath");
            }
            _xmlPath = AppDomain.CurrentDomain.BaseDirectory + cacheFilePath;
            _xDocuments = new Dictionary<string, XDocument>();
     
            foreach (var file in Directory.GetFiles(_xmlPath))
            {
                if(!string.IsNullOrEmpty(file) && Path.GetExtension(file).Equals("xml", StringComparison.InvariantCultureIgnoreCase))
                {
                    var xDoc = XDocument.Load(file);
                    var id = xDoc.XPathSelectElement("/languages/language").Attribute("id");
     
                    if(Path.GetFileName(file).Equals(string.Format(_xmlFilename, id))) // only add xml files with the cache xml files
                    {
                        _xDocuments.Add(xDoc.XPathSelectElement("/languages/language").Attribute("id").Value, xDoc);
                    }
                }
            }
        }
     
     
        /// <summary>
        /// Insert
        /// </summary>
        /// <param name="culture"></param>
        /// <param name="path"></param>
        /// <param name="value"></param>
        public void Insert(CultureInfo culture, string[] path, string value)
        {
            var xDoc = GetByCulture(culture);
            var xElement = xDoc.XPathSelectElement("/languages/language");
            var builder = new StringBuilder();
     
            for (var i = 0; i < (path.Length - 1); i++)
            {
                if (i > 0)
                {
                    builder.Append(path[i]);
                }
                builder.Append(path[i]);
     
                if (xDoc.XPathSelectElement(_languageSelectorPreset + builder) == null)
                {
                    xElement.Add(new XElement(path[i]));
                }
                xElement = xDoc.XPathSelectElement(_languageSelectorPreset + builder);
            }
            var element = xDoc.XPathSelectElement(_languageSelectorPreset + builder);
     
            element.Add(new XElement(path[path.Length - 1], value));
            xDoc.Save(XmlFilenamePath(culture));
        }
     
        /// <summary>
        /// Get
        /// </summary>
        /// <param name="culture"></param>
        /// <param name="path"></param>
        /// <returns></returns>
        public string Get(CultureInfo culture, string path)
        {
            var xDoc = GetByCulture(culture);
     
            var element = xDoc.XPathSelectElement(_languageSelectorPreset + path);
     
            if (element != null)
            {
                return element.Value;
            }
            return string.Empty;
        }
     
        /// <summary>
        /// Contains
        /// </summary>
        /// <param name="culture"></param>
        /// <param name="path"></param>
        /// <returns></returns>
        public bool Contains(CultureInfo culture, string path)
        {
            var xDoc = GetByCulture(culture);
     
            return xDoc.XPathSelectElement(_languageSelectorPreset + path) != null;
        }
     
        /// <summary>
        /// Get by culture
        /// </summary>
        /// <param name="culture"></param>
        /// <returns></returns>
        private XDocument GetByCulture(CultureInfo culture)
        {
            if (!_xDocuments.ContainsKey(culture.TwoLetterISOLanguageName)) // does not exists so create new xml file
            {
                var xDoc = new XDocument(
                    new XElement("languages",
                        new XElement("language", new XAttribute("name", culture.EnglishName),
                            new XAttribute("id", culture.TwoLetterISOLanguageName))));
     
                xDoc.Save(XmlFilenamePath(culture));
                _xDocuments.Add(culture.TwoLetterISOLanguageName, xDoc);
                return xDoc;
           }
            return _xDocuments[culture.TwoLetterISOLanguageName];
        }
    }

So this way the whole process of translating labels can be automated. This means that the developer doesn’t needs to update all language XML files every time a new label should be translated. I don’t say that this solution can be used in production environments, because of the not always fine translated values of online translation tools, but for a developer it’s really helpful while developing /testing a multi language website.

The complete source code can be found on my GitHub account.

All the functionality that’s handled in this blog can be found under the namespace ‘Site.Business.Localization’.

Comments