AJ's blog

November 23, 2013

ASP.NET MVC I18n – Part 2: Detect Browser Settings

Filed under: .NET, .NET Framework, ASP.NET MVC, HTML5, Internationalization — ajdotnet @ 5:47 pm

If you want to cater to the user’s language and region choice, the first thing to do is actually take note of his choice.

Note: This post is part of a series

While you could simply start with one default language and let him switch the language using some control, it is probably far more "polite" to use the fact that he already tells you his preferences: Each browser has the ability to let the user chose his is language preferences:

This information is sent with each request via the accept-language http header, which contains a collection of weighted language tags, say the following header with the above settings:

Accept-Language: de-DE,de;q=0.8,en-US;q=0.5,en;q=0.3

This is very basic stuff and I’m only going into this detail because I have actually seen code that uses the header value as if it contains exactly one language and ignoring the weight.

The simple approach…

“All” we have to do is grab the language from the header and tell .NET for each request:

protected void Application_OnBeginRequest()
{
    CultureHelper.ApplyUserCulture(this.Request);
}

The CultureHelper class contains the plumbing, we’re going to set up:

public static void ApplyUserCulture(this HttpRequest request)
{
    ApplyUserCulture(request.Headers);
}

static void ApplyUserCulture(NameValueCollection headers)
{
    var culture = GetUserCulture(headers) ?? CultureInfo.GetCultureInfo("en-US");
    
    var t = Thread.CurrentThread;
    t.CurrentCulture = culture;
    t.CurrentUICulture = culture;
    Debug.WriteLine("Culture: " + culture.Name);
}

Actually determining the user’s choice is simple: 

static CultureInfo GetUserCulture(NameValueCollection headers)
{
    // Accept-Language: de, en-US;q=0.8
    string acceptLanguages = headers["Accept-Language"];
    if (string.IsNullOrEmpty(acceptLanguages))
        return null;
    // split languages _and_ weights
    var cultureName = acceptLanguages.Split(‘,’, ‘;’)[0];
    return GetCultureInfo(cultureName);
}

private static CultureInfo GetCultureInfo(string language)
{
    try
    {
        return CultureInfo.GetCultureInfo(language);
    }
    catch (CultureNotFoundException)
    {
        return null;
    }
}

Note the exception handling. This is necessary due to the different notions of "language" (see the last post), and the possibility that .NET might not support Klingon. Most people will never actually encounter this issue during regular operations, but there is the occasional exception (no pun intended). And of course the possibility of malicious requests…

Accessing the header value and setting the cultures is something that can be found in every blog post and tutorial. Actually ASP.NET would do this automatically (see also Scott’s post).

Unfortunately this is simply too shortsighted.

 

Matching the user’s language choice…

I don’t want the user’s first language choice applied unconditionally. I want the user’s language choices that I can best support with my supported cultures.

My application is going to support English and German. What if my French colleague showed up? We could set the culture to “fr-FR” (as, in fact, the above code will do). Since there are no French localizations, proper fallback strategy should ensure he gets English texts. Is this really the best choice, if he asks for “fr-FR,de-DE,en-US”? And what about date formats? Is the meaning of 01/06/2013 immediately obvious? Does he even suspect, that – within an otherwise English UI – this is actually the first of June (interpretation as dd/mm/yyyy) – not epiphany (mm/dd/yyyy)?
Thus it is better to present him a consistent (in this case American English, i.e. en-US) version, rather than a confusing mix.

So, what I want is matching the users’ list of preferred regions (all of the header values, not just the first one!) to the collection of regions my application supports, and set the culture to the best match.

This is something I have rarely seen addressed, much less correctly. E.g. Nadeem is one of the few who actually recognized that necessity; still, his implementation does not quite meet my expectations.

 

Let’s make that crystal clear: Say my application supports en-US and de-DE, with en-US the default.

Perfect matches:

  • If the user’s request is "de-DE, en-US;q=0.8", then de-DE is the perfect match. (Obviously.) Likewise, if the user’s request is "fr-FR, en-US;q=0.8", then en-US is the perfect match. Not the users’ first choice, but anyway. Also if the user’s request is "fr-FR, de-DE;q=0.8, en-US;q=0.5", then de-DE is the perfect match. This is a case which most, if not all implementations I have seen, are missing.
  • Something that is also missed quite often: If his request is "de, en-US;q=0.8", then de-DE is still the correct choice, because de encompasses all German regions.
  • If his request is "de-AT, en-US;q=0.8", then en-US is the best choice, because it’s a perfect match, while de-AT matches de-DE only partly. This is debatable, but since he could control that via the browser settings, it’s better than other interpretations.

Partial matches:

  • If his request is "de-AT, fr-FR;q=0.8", then we have no perfect match. But de-DE matches de-AT at least partly, so it would be a better choice, than using the fallback to the default region en-US.

No match:

  • Only if no requested region matches any of the supported cultures even slightly, e.g. "es-ES, fr-FR;q=0.8", the default region en-US is used – nothing else that could be done.

Now, this requires a little more code than before…

 

Parsing the header…

The Accept-Language header generally contains entries with weights (usually they are sorted according to weight anyway, but I don’t like to rely on that). Parsing it is relatively straightforward, but requires some groundwork:

public static CultureInfo[] GetUserCultures(string acceptLanguage)
{
    // Accept-Language: fr-FR , en;q=0.8 , en-us;q=0.5 , de;q=0.3
    if (string.IsNullOrWhiteSpace(acceptLanguage))
        return new CultureInfo[] { };
    
    var cultures = acceptLanguage
        .Split(‘,’)
        .Select(s => WeightedLanguage.Parse(s))
        .OrderByDescending(w => w.Weight)
         .Select(w => GetCultureInfo(w.Language))
         .Where(ci => ci != null)
         .ToArray();
    return cultures;
}

WeightedLanguage is a little helper class:

class WeightedLanguage
{
    public string Language { get; set; }
    public double Weight { get; set; }
    
    public static WeightedLanguage Parse(string weightedLanguageString)
    {
        // de
        // en;q=0.8
        var parts = weightedLanguageString.Split(‘;’);
        var result = new WeightedLanguage { Language = parts[0].Trim(), Weight = 1.0 };
        if (parts.Length > 1)
        {
            parts[1] = parts[1].Replace("q=", "").Trim();
            double d;
            if (double.TryParse(parts[1], NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out d))
                result.Weight = d;
        }
        return result;
    }
}

Maintaining supported cultures…

Getting the supported cultures can be done via some configuration… – or just be hardcoded. So let’s just assume we have the following array, and we will use first culture as default culture:

public static readonly CultureInfo[] SupportedCultures = new CultureInfo[]
{
    CultureInfo.GetCultureInfo("en-US"),
    CultureInfo.GetCultureInfo("de-DE"),
    //CultureInfo.GetCultureInfo("fr-FR"),
    //CultureInfo.GetCultureInfo("es-ES"),
};

Matching…

Now we can start matching the user’s cultures and the supported cultures.

public static CultureInfo GetUserCulture(NameValueCollection headers)
{
    var acceptedCultures = GetUserCultures(headers["Accept-Language"]);
    var culture = GetMatchingCulture(acceptedCultures, SupportedCultures);
    return culture;
}

Actually matching requires two passes. The first pass is looking for the perfect match; if none is found, the second pass looks for an imperfect match:

public static CultureInfo GetMatchingCulture(CultureInfo[] acceptedCultures, CultureInfo[] supportedCultures)
{
    return
        // first pass: exact matches as well as requested neutral matching supported region
        // supported: en-US, de-DE
        // requested: de, en-US;q=0.8
        // => de-DE! (de has precendence over en-US)
        GetMatch(acceptedCultures, supportedCultures, MatchesCompletely)
        // second pass: look for requested neutral matching supported _neutral_ region
        // supported: en-US, de-DE
        // requested: de-AT, en-GB;q=0.8
        // => de-DE! (no exact match, but de-AT has better fit than en-GB)
        ?? GetMatch(acceptedCultures, supportedCultures, MatchesPartly);
}

GetMatch traverses the user’s accepted cultures, and finds the first matching supported culture:

public static CultureInfo GetMatch(
    CultureInfo[] acceptedCultures, 
    CultureInfo[] supportedCultures,
    Func<CultureInfo, CultureInfo, bool> predicate)
{
    foreach (var acceptedCulture in acceptedCultures)
    {
        var match = supportedCultures
            .Where(supportedCulture => predicate(acceptedCulture, supportedCulture))
            .FirstOrDefault();
        if (match != null)
            return match;
    }
    return null;
}

The predicates do the actual match for a pair of cultures. For the perfect match this includes a requested neutral culture matching a region, see the second example above.

static bool MatchesCompletely(CultureInfo acceptedCulture, CultureInfo supportedCulture)
{
    if (supportedCulture.Name == acceptedCulture.Name)
        return true;
    // acceptedCulture could be neutral and supportedCulture specific, but this is still a match (de matches de-DE, de-AT, …)
    if (acceptedCulture.IsNeutralCulture)
    {
        if (supportedCulture.Parent.Name == acceptedCulture.Name)
            return true;
    }
    return false;
}

For an imperfect match, we simply compare the neutral cultures (i.e. the parents of specific cultures):

static bool MatchesPartly(CultureInfo acceptedCulture, CultureInfo supportedCulture)
{
    supportedCulture = supportedCulture.Parent;
    if (!acceptedCulture.IsNeutralCulture)
        acceptedCulture = acceptedCulture.Parent;
    
    if (supportedCulture.Name == acceptedCulture.Name)
        return true;
    return false;
}

Finally.

You may want to print the culture on the home view:

Current culture is: @UICulture

UICulture is a shortcut available from the view base class.

Quite a bunch of code one has to write himself for a regular demand, if you ask me.

That’s all for now folks,
AJ.NET

Advertisement

Leave a Comment »

No comments yet.

RSS feed for comments on this post. TrackBack URI

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Blog at WordPress.com.

%d bloggers like this: