Localizing DB content with valueForKeyPath();

It is fairly straight forward to localize a WebObjects application using either localized components or localized strings as I wrote about here. But things get a little more complex when you need to localize content coming from a database.

Usually multilingual database content is modeled something like this:

product_model.gif

Regardless of your model though, your task is usually to identify the correct object from an array based on its language key. You might do that in your Product EO:

public ProductDescription englishDescription() {
   NSArray descriptions = CBArrayUtilities.filteredArrayWithKeyValueEqual(
						descriptions(),
						"language",
						"English");
   if (descriptions != null && descriptions.count() > 0 ) {
      return descriptions.lastObject();
   }
   return null;
}

Note: The source for the filteredArrayWithKeyValueEqual method is here

Or you might move that logic into your components. Either way these kinds of solutions tend to result in:

  1. Lots of code in lots of places (Entities, Components, etc.).
  2. Adding supported languages require massive code changes. New accessors, component level changes, etc… yuck.

I prefer a more flexible approach. I like to get my components to recognize a new @localized key path operator by overriding their valueForKeyPath() method. Since my components usually inherit from a custom WOComponent subclass (ie. CBLocalizedComponent) this is easy to do. This example code shows one way you might do this:

/*
* Overriding WOComponent's valueForKeyPath method to 
* get it to recognize the @localized operator.
*/ 
public Object valueForKeyPath(String keypath) {
   String localizedOperator = "@localized";
   int locOpLength = localizedOperator.length();
   String sourceKey, valueKey;
   int index = keypath.indexOf(localizedOperator);
   if (index > -1) { 
      /*
       * If the index of the localizedOperator is > -1 then it is contained
       * in the keyPath, and we should proceed.
       * First, extract the parts of the keypath before and after the 
       * localizedOperator
       */
      sourceKey = keypath.substring(0, index - 1);
      valueKey = keypath.substring(index + locOpLength + 1, keypath.length());
      /*
       * Use the sourceKey to give us our array of objects
       */
      NSArray source = (NSArray)this.valueForKeyPath(sourceKey);
      /*
       * Filter the source array for the current language, we're assuming 
       * it only contains one object so get it.
       */
      NSArray filtered = (NSArray)this.filteredArrayForCurrentLanguage(source);
      NSKeyValueCodingAdditions object = 
		(NSKeyValueCodingAdditions)filtered.lastObject();
      if (object != null) {
         if (valueKey != null &&  valueKey.length() > 0) {
            /*
             * If the object and the valueKey isn't null, use the valueKey 
             * to get the requested value
             */
            return object.valueForKeyPath(valueKey);
         }
         /* 
          * Otherwise, return the object
          */
         return object;
      }
      return null;
   }
   /*
    * If the index of the localizedOperator is -1 then we can just
    * let super deal with it
    */
   return super.valueForKeyPath(keypath);
}
 
/*
* Utility method to filter an array returning the objects that match the
* current selected language. If no objects exist for that language return
* the default language.
* 
* It is assumed that the objects contained in the array will all implement
* a "language" attribute.
*/  
public NSArray filteredArrayForCurrentLanguage(NSArray array) {
   NSArray filteredArray = new NSArray();
   NSArray availableLanguages = (NSArray)array.valueForKeyPath("language");
   String currentLanguage = ((Session)session()).currentSelectedLanguage();
   if (availableLanguages.containsObject(currentLanguage)) {
      filteredArray = CBArrayUtilities.filteredArrayWithKeyValueEqual(array, 
							"language", 
							currentLanguage);
   } else {
      String defaultLanguage = Session.DEFAULTLANGUAGE;
      filteredArray = CBArrayUtilities.filteredArrayWithKeyValueEqual(array, 
							"language", 
							defaultLanguage);
   }
   return filteredArray;
}

The code in the WOComponents rely on some values being available from the Session. This is an example of that code:

protected NSArray _requestedLanguages;
protected String _currentSelectedLanguage;
public static String DEFAULTLANGUAGE = "English";
 
/* 
 * Get the array of requested languages from the browser request. 
 * I'm using ERXSession, ERContext, and ERXRequest from ProjectWONDER
 * The ERXRequest appends a "NonLocalized" value at the end of this array
 * that will not exist if you use the standard WORequest. You will need
 * modify the code accordingly.
 */
public NSArray requestedLanguages() {
   if (_requestedLanguages == null) {
      _requestedLanguages = this.context().request().browserLanguages();
   }
   return _requestedLanguages;
}
   
public void setRequestedLanguages(NSArray array) {
   _requestedLanguages = array;
}
  
/*
 * Identify the first requestedLanguage that matches the available languages
 * for this application. If none of them match, use the default language.
 * For performance reasons we cache this once per Session.
 *
 * Notes:
 * The ERXSession.availableLanguagesForTheApplication() returns an array
 * of the languages currently supported by the application. If you are not
 * using ProjectWONDER:
 *    - You will need to do identify the languages your application supports 
 *    - You will need to handle the "NonLocalized" browser request differently.
 */
public String currentSelectedLanguage() {
   if (_currentSelectedLanguage == null) {
      int rlc = requestedLanguages().count();
      for (int i = 0; i < rlc; i ++ ) {
         String lang = (String)requestedLanguages().objectAtIndex(i);
         if (lang.equals("Nonlocalized")) {
            _currentSelectedLanguage = DEFAULTLANGUAGE;
            return _currentSelectedLanguage;
         } else if (availableLanguagesForTheApplication().containsObject(lang)){
            _currentSelectedLanguage = lang;
            return _currentSelectedLanguage;
         }
      }
      _currentSelectedLanguage = DEFAULTLANGUAGE;
   }
   return _currentSelectedLanguage;
}
       
public void setCurrentSelectedLanguage(String value) {
   _currentSelectedLanguage = value;
}

With this code in place we can bind values in our WOComponants with key paths that look something like this:

eman.dezilacolnull@.snoitpircsed.tcudorp

Our modified components will recognize the @localized operator, and return only the description that matches the current selected language. We can easily add additional languages to our application without having to change any of the code in any of our WOComponents or EOs, and new EOs in our model only need to implement a language attribute to support full localization.

Note: Like any example code, this shows only one possible way you might chose to implement this. Feel free to use it as a starting point. The code was tested before I started marking it up, if you find any errors please let me know. No warrantee implied, blah, blah, blah. 🙂

Fortune Cookies

A trip for Chinese food yesterday at a local restaurant resulted in some awe inspiring fortune cookie messages:

fortune1.jpg

I’m not sure, is this a command or a prophecy?

This one is just left me speechless:

fortune2.jpg

Maybe it means all that stuff he/she put off for the eighth day… I dunno.

RSS Full Posts!

Well, the response has been quite overwhelming. Our servers were flooded with responses to our query and we had to buttress our LAN room with spare timber to prevent it from collapsing. Thankfully the hardware held and our representatives from PriceWaterhouseCoopers spent the morning tallying the results.

We know you are you are all waiting with baited breath for the results so we will try not to delay. The voting was close and although we did have to discard several votes due to dangling chads We think we have a winner.

We held a minor ceremony here at reBeLog headquarters, toasted the future, drank some vino, and threw the switch:

fullText.jpg

The RSS feed should now contain full articles!