Click here to Skip to main content
15,867,838 members
Articles / Web Development / ASP.NET
Article

Creating Multilingual Websites - Part 3

Rate me:
Please Sign up or sign in to vote.
4.76/5 (24 votes)
1 Nov 200513 min read 218.7K   3K   173   72
Extend the existing globalization capabilities of .NET to create flexible and powerful multilgual web sites. This third part won't focus on the fundamental but rather enhancements to what we've already covered.

Table of Contents

Introduction

Over a year ago, I wrote a two part article on creating multilingual web applications in ASP.NET. The first article focused on a custom resource manager which solved a lot of problems associated with the built-in functionality, as well as a set of custom server controls which made creating multilingual websites painless. The second article covered a number of issues, including URL rewriting, data model design and enhanced custom server controls. This third part won't focus on the fundamental but rather enhancements to what we've already covered. Some of the features, notably the first, will greatly increase the complexity of our solution. If you don't need it, I recommend you don't implement it - none of the other features require it.

Readers who haven't read the first two parts of this series will be utterly left out, so go read them now! (Part 1, Part 2)

Database Driven

The first and most significant change will be to modify the ResourceManager to support a variety of sources for localized content. Making the ResourceManager support SQL Server had always been intended from the start, but in the end, a simpler codebase was chosen. In the projects I'm involved with, XML files are still my preference, but your project might have good reasons to use something else.

Provider Factory Pattern

The approach we'll use to make our ResourceManager agnostic of the underlying storage mechanism is the Provider Factory Pattern. This pattern is at the center of many ASP.NET 2.0 features, so you should get familiar with it. Without going into too much detail, the Provider Factory Pattern works by using an abstract class to publish an interface and relies on other classes to implement the functionality. In other words, we'll still have our ResourceManager class which will be a shell as well as a ResourceManagerXml class and a ResourceManagerSql class. The design pattern is powerful because it allows third parties to create their own implementation. Anyone could create a new class called ResourceManagerAccess which inherits from ResourceManager and implements the logic needed to work with an Access database. You can configure which implementation you'll use via the web.config. The code we'll go over should give you a good hands-on feel for the design pattern, but if you are interested in knowing more, make sure to visit MSDN.

ResourceManager and ResourceManagerXml

The first step is to turn our ResourceManager into an abstract class which defines what members our providers (those that implement the actual logic) will have to implement.

C#
public abstract class ResourceManager
{
   protected abstract string RetrieveString(string key);
}

It'd be nice to reuse the name GetString, but since we want to keep our ResourceManager compatible with the previous version, we must use a new name. If you are unfamiliar with the abstract keyword on a member, it simply means that any classes which inherit from ResourceManager must implement the RetrieveString function. Note also that if any member of a class is abstract, the class itself must be marked as abstract. This means you can't create a new instance of the class. What would happen if you created a new instance of ResourceManager and then tried to call the unimplemented RetrieveString function? Having a class that you can't create an instance of might seem like a waste of bytes, but let's see how it's actually a solid use of OO design. We now create our ResourceManagerXml class, which is XML-aware.

C#
public class ResourceManagerXml: ResourceManager
{
   protected override string RetrieveString(string key)
   {
      NameValueCollection messages = GetResource();
      if (messages[key] == null)
      {
         messages[key] = string.Empty;
#if DEBUG
         throw new ApplicationException("Resource value not found for key: " + key);
#endif
      }
      return messages[key];
   }
}

As you can see, all we've done is create a layer of abstraction and move the GetString functionality from the previous version of the ResourceManager here. We've also moved all supporting functions out of the ResourceManager and into the ResourceManagerXml class, namely the private functions GetResource and LoadResource (not shown here).

Since ResourceManagerXml inherits from ResourceManager, we can cast it to ResourceManager, much like a string can be cast to an object. This is where we tie the two classes together. Inside the ResourceManager class, we create a property called Instance:

C#
public abstract class ResourceManager
{
   internal static ResourceManager Instance
   {
       get { return new ResourceManagerXml(); }
   }

   public static string GetString(string key)
   {
      return Instance.RetrieveKey(key);
   }

   protected abstract string RetrieveString(string key);
}

As is hopefully clear, GetString now calls RetrieveString of the ResourceManagerXml class, through the Instance method. If we now create a ResourceManagerSql and make the Instance property return an instance of it, the call to RetrieveString will be handled by the SQL implementation. To be truly useful, our ResourceManager's implementation is slightly more complicated. It dynamically creates the child class based on values in the web.config. That means if you want to switch from XML to SQL Server, you don't need to change the class and recompile, but simply change values in the configuration file. Here's the actual implementation of the relevant code:

C#
public abstract class ResourceManager
{
   private static ResourceManager instance = null;
      
   static ResourceManager()
   {
      Provider provider = LocalizationConfiguration.GetConfig().Provider;
      Type type = Type.GetType(provider.Type);
      if (type == null)
      {
         throw new ApplicationException(string.Format("Couldn't" + 
                               " load type: {0}", provider.Type));
      }
      object[] arguments = new object[] {provider.Parameters};
      instance = (ResourceManager) Activator.CreateInstance(type, arguments);
   }

   internal static ResourceManager Instance
   {
      get { return instance; }
   }
}

The static construct (which is automatically private and guaranteed by .NET to be called only once when any member of the ResourceManager is first called) gets a provider from the configuration, tries to get the type and instantiates the object. The Type.GetType method is able to take a string in the form or Namespace.ClassName, AssemblyName and create a Type object, which can then be instantiated with the Activator.CreateInstance. There's a performance penalty to this type of dynamic invocation, so we store the instance in a private static variable which will be used throughout the lifetime of the application (in other words, the expensive code only fires once). We won't go over the changes made to the LocalizationConfiguration to support the provider (the downloadable code is well documented), but it basically supports the following type of data in our web.config:

XML
<Localization 
   ...
   providerName="XmlLocalizationProvider"
 >
   <Provider> 
      <add 
         name="XmlLocalizationProvider"
         type="Localization.ResourceManagerXml, Localization"
         languageFilePath="c:\inetpub\wwwroot\localizedSample\Language"
      />
      <add 
         name="SqlLocalizationProvider"
         type="Localization.ResourceManagerSql, Localization"
         connectionString="Data Source=(local);Initial 
                           Catalog=DATABASE;User Id=sa;Password=PASSWORD;"
      />
   </Provider>
</Localization>

Multiple providers are supported, but only the one specified by the providerName will be loaded. The name and the type attributes of each provider is used to load the actual instance, all other attributes are passed to the constructor of the provider as a NameValueCollection. We'll see an example of this next, when we take a look at our ResourceManagerSql.

ResourceManagerSql

The framework is now in place to use any technology for storing localized content. Creating one to work with SQL Server requires only three steps: creating our database model, the ResourceManagerSql class and the necessary stored procedure. The model we'll use is similar to the approach for normalizing content discussed in Part 2 of this series. A simpler, less normalized model could also be adopted. Also, we've specified a value of 1024 characters, but we could pick a NVARCHAR up to 4196 or even a Text field. It would even be possible to use a VARCHAR and Text column and pull from one or the other (not the prettiest design, but it might be necessary and practical).

Data Model

Next we create the ResourceManagerSql class. This class is very similar to the XML one, except that the LoadResources method interacts with SQL Server via the System.Data classes rather than XML files. We start with the constructor which, as we saw in the previous section, is dynamically called and passed a NameValueCollection:

C#
private string connectionString;
private int cacheDuration;

public ResourceManagerSql(NameValueCollection parameters)
{
   if (parameters == null || parameters["connectionString"] == null)
   {
      throw new ApplicationException("ResourceManagerSql" + 
            " requires connectionString attribute in configuraiton.");
   }
   connectionString = parameters["connectionString"];

   //load the optional cacheDuration parameter, 
   //else we'll cache for 30 minutes
   if (parameters["cacheDuration"] != null)
   {
      cacheDuration = Convert.ToInt32(parameters["cacheDuration"]);
    }
    else
    {
       cacheDuration = 30;
    }
}

No magic is happening here, read the mandatory connectionString parameter (or throw an exception if there isn't one) and read the optional cacheDuration parameter or use a default value.

Since our class inherits from ResourceManager, it must implement the RetrieveString method. This method is identical to the XML equivalent:

C#
protected override string RetrieveString(string key)
{
   NameValueCollection messages = GetResources();
   if (messages[key] == null)
   {
      messages[key] = string.Empty;
#if DEBUG
      throw new ApplicationException("Resource value" + 
                         " not found for key: " + key);
#endif
   }
   return messages[key];
}

The real difference happens in the GetResources and LoadResources methods:

C#
private NameValueCollection GetResources()
{
   string currentCulture = ResourceManager.CurrentCultureName;
   string defaultCulture = LocalizationConfiguration.GetConfig().DefaultCultureName;
   string cacheKey = "SQLLocalization:" + defaultCulture + ':' + currentCulture;
   NameValueCollection resources = (NameValueCollection) HttpRuntime.Cache[cacheKey];
   if (resources == null)
   {
      resources = LoadResources(defaultCulture, currentCulture);
      HttpRuntime.Cache.Insert(cacheKey, resources, null, 
                  DateTime.Now.AddMinutes(cacheDuration), 
                              Cache.NoSlidingExpiration);
   }
   return resources;
}

private NameValueCollection LoadResources(string defaultCulture, 
                                          string currentCulture)
{
   SqlConnection connection = null;
   SqlCommand command = null;
   SqlDataReader reader = null;
   NameValueCollection resources = new NameValueCollection();
   try
   {
      connection = new SqlConnection(connectionString);
      command = new SqlCommand("LoadResources", connection);
      command.CommandType = CommandType.StoredProcedure;
      command.Parameters.Add("@DefaultCulture", 
              SqlDbType.Char,5).Value = defaultCulture;
      command.Parameters.Add("@CurrentCulture", 
              SqlDbType.Char,5).Value = currentCulture;
      connection.Open();
      reader = command.ExecuteReader(CommandBehavior.SingleResult);
      int nameOrdinal = reader.GetOrdinal("Name");
      int valueOrdinal = reader.GetOrdinal("Value");
      while(reader.Read())
      {
         resources.Add(reader.GetString(nameOrdinal), 
                     reader.GetString(valueOrdinal));
      }
   }
   finally
   {
      if (connection != null)
      {
         connection.Dispose();
      }
      if (command != null)
      {
         command.Dispose();
      }
      if (reader != null && !reader.IsClosed)
      {
         reader.Close();
      }
   }
   return resources;
}

This should be similar to any SQL code you've written before. The main difference with this approach and the XML one is that we've pushed the fallback logic to the stored procedure. This helps reduce calls to the database. Also, since we can't add a FileDependency for our cache, we set an absolute expiration time, changeable via the web.config.

Finally, all that's left is the LoadResources stored procedure:

SQL
CREATE PROCEDURE LoadResources
(
  @DefaultCulture CHAR(5),
  @CurrentCulture CHAR(5)
)
AS
SET NOCOUNT ON

  SELECT R.[Name], RL.[Value]
   FROM Resources R 
      INNER JOIN ResourcesLocale RL ON R.Id = RL.ResourceId
      INNER JOIN Culture C ON RL.CultureId = C.CultureId
   WHERE C.Culture = @CurrentCulture
   
   UNION ALL

  SELECT R.[Name], RL.[Value]
   FROM Resources R 
      INNER JOIN ResourcesLocale RL ON R.Id = RL.ResourceId
      INNER JOIN Culture C ON RL.CultureId = C.CultureId
   WHERE C.Culture = @DefaultCulture
    AND R.[Name] NOT IN (
        SELECT [Name] FROM Resources R2 
          INNER JOIN ResourcesLocale RL2 ON R2.Id = RL2.ResourceId
          INNER JOIN Culture C2 ON RL2.CultureId = C2.CultureId
       WHERE C2.Culture = @CurrentCulture
    )
SET NOCOUNT OFF

The stored procedure is made a little more complex than might seem necessary. However, our data model is well normalized, meaning we need multiple JOINs and we decided to push the fallback logic to the stored procedure.

Final Considerations

There's some shared code between the XML and SQL implementations. This functionality could be pushed into the abstract ResourceManager. For example, the RetrieveString method (which is identical in both cases) could be placed in ResourceManager and instead the GetResource method could be abstract. Similarly, caching could be implemented in the ResourceManager rather than in each implementation. However, you can never tell how a specific provider will be implemented, and I'd hate to make an assumption that would make a provider difficult to develop. For example, just because the SQL and XML providers make use of a NameValueCollection, doesn't mean an Oracle one would.

Something else to keep in mind is that the interface of ResourceManager is well defined, but the internal implementation is totally private. This means you can make changes and tweaks as you see fit without having to worry about breaking existing code. The provider model itself promotes good programming practices that you should try to emulate, when appropriate, in your own code.

Image Support

The next feature we'll add is support for localizing images. This is a useful demonstration of how to localize a group of content. An image has three typical values that need to be localized, the height, width and alt tags. Height and width might not make any sense, but images of words will often be of different length depending on the culture. The solution I've seen for this in the past is to reuse the GetString method and a custom naming convention. For example, I've often seen:

C#
img.Alt = ResourceManager.GetString("Image_Welcome_Alt");
img.Width = Convert.ToInt32(ResourceManager.GetString("Image_welcome_Width));
img.Height = Convert.ToInt32(ResourceManager.GetString("Image_Welcome_Height"));

This solution is both inelegant and error prone. Instead we'll build a GetImage method which returns a LocalizedImageData. LocalizedImageData is a simple class that contains all our localized properties:

C#
public class LocalizedImageData
{
   private int width;
   private int height;
   private string alt;

   public int Width
   {
     get { return width; }
     set { width = value; }
   }
   public int Height
   {
     get { return height; }
     set { height = value; }
   }
   public string Alt
   {
     get { return alt; }
     set { alt = value; }
   }

   public LocalizedImageData()
   {
   }
   public LocalizedImageData(int width, 
                int height, string alt)
   {
      this.width = width;
      this.height = height;
      this.alt = alt;
   }
}

Now our GetImage function has something to return - a class that groups all the localized content. We implement the GetImage function in the ResourceManager. Like the newest GetString we covered in the first section of this article, it'll rely on an abstract RetrieveImage function which each provider will have to implement. We'll only cover the XML implementation in this article, but the downloadable code also has it implemented in the SQL class.

First we create the GetImage function in ResourceManager:

C#
public static LocalizedImageData GetImage(string key)
{
   return Instance.RetrieveImage(key);
}

Next we create the abstract RetrieveImage function:

C#
protected abstract LocalizedImageData RetrieveImage(string key);

Finally, we implement RetrieveImage in the ResourceManagerXml class:

C#
protected override LocalizedImageData RetrieveImage(string key)
{
   Hashtable imageData = GetImages();
   if (imageData[key] == null)
   {
      imageData[key] = new LocalizedImageData(0,0,string.Empty);
#if DEBUG
      throw new ApplicationException("Resource value not found for key: " + key);
#endif
   }
   return (LocalizedImageData) imageData[key];
}

We go over this code quickly because it's almost identical to the GetString method. Instead of calling LoadResources however, GetImages is called. This is where the code starts to get a little different. We could have used the same XML file to store a new type of data, but decided a different file might help keep things clean. The main difference happens in the parsing of the XML file:

C#
private void LoadImage(Hashtable resource, string culture, string cacheKey)
{
   string file = string.Format("{0}\\{1}\\Images.xml", fileName, culture);
   XmlDocument xml = new XmlDocument();
   xml.Load(file);
   foreach (XmlNode n in xml.SelectSingleNode("Images"))
   {
      if (n.NodeType != XmlNodeType.Comment)
      {
         LocalizedImageData data = new LocalizedImageData();
         data.Alt = n.InnerText;
         data.Height = Convert.ToInt32(n.Attributes["height"].Value);
         data.Width = Convert.ToInt32(n.Attributes["width"].Value);
         resource[n.Attributes["name"].Value] = data;
      }
   }
   HttpRuntime.Cache.Insert(cacheKey, resource, 
               new CacheDependency(file), 
               DateTime.MaxValue, TimeSpan.Zero);
}

Since the XML structure is a little more complex, there's more work to be done in the LoadImages function, but in reality, it's all pretty straightforward. The XML file we use looks something like:

XML
<Images>
   <item name="Canada" width="10" height="10">Canada!</item>
   ...
   ...
</Images>

Finally, the last step is to create our localized controls:

C#
public class LocalizedHtmlImage : HtmlImage, ILocalized
{
   private const string imageUrlFormat = "{0}/{1}/{2}";
   private string key;
   private bool colon = false;
   public bool Colon
   {
      get { return colon; }
      set { colon = value; }
   }
   public string Key
   {
      get { return key; }
      set { key = value; }
   }

   protected override void Render(HtmlTextWriter writer)
   {
      LocalizedImageData data = ResourceManager.GetImage(key);
      if (data != null)
      {
         base.Src = string.Format(imageUrlFormat, 
                    LocalizationConfiguration.GetConfig().ImagePath, 
                    ResourceManager.CurrentCultureName, base.Src);
         base.Width = data.Width;
         base.Height = data.Height;
         base.Alt = data.Alt;
      }
      if (colon)
      {
         base.Alt += ResourceManager.Colon;
      }
      base.Render(writer);
   }
}

Like all localized controls, our class implements the ILocalized interface which defines the two properties Key and Colon. Rather than calling GetString in the Render method however, we call GetImage and set the appropriate image values based on the returned LocalizedImageData class. Finally, note that the src of the image is also localized. Basically, if you specify src="Welcome.gif", it'll be turned into src="/images/en-CA/welcome.gif" assuming your web.config specified "/images/" as the ImagePath and en-Ca as the current culture.

We went through the image localization exercise rather quickly. This is in large part due to the similarity with the existing code. Almost all other groups of localized data can be done the same way. For example, you could localize emails (subject and body) using the same approach.

JavaScript Localization

We've done a good job of providing all the necessary tools one would need to provide a rich multilingual experience to our users. One aspect of our UI still needs to have some localization capabilities: JavaScript. With the growing popularity of AJAX, JavaScript's role will only grow. Even the most common JavaScript validation needs to be localized. We need to build some functionality directly into the client.

Our design will be to try and emulate in JavaScript what we've already built server-side. Ideally, we want to be able to do ResourceManager.GetString(XYZ); in JavaScript and get the localized value. One solution would be to use AJAX, but that might be too intensive for many applications. Instead, we'll create a couple utility functions which dump localized content into a JavaScript array. We'll wrap the array in a JavaScript object which exposes a GetString method. Thanks to some unique features of JavaScript, the code is surprisingly compact. The downside is that the entire localized content won't be available, rather we'll have to specify which values we want available during Page_Load. First we'll look at the JavaScript methods:

JavaScript
var ResourceManager = new RM();
function RM()
{
  this.list = new Array();
};
RM.prototype.AddString = function(key, value)
{
  this.list[key] = value;
};
RM.prototype.GetString = function(key)
{
  var result = this.list[key];  
  for (var i = 1; i < arguments.length; ++i)
  {
    result = result.replace("{" + (i-1) + "}", arguments[i]);
  }
  return result;
};

If you aren't familiar with JavaScript objects, the above code might seem a little odd. Basically we create a new instance of the RM class (client-side), which just contains an array. Next we create two members, AddString and GetString. You may not know this, but JavaScript arrays don't need to be indexed by integers. Rather, they can be associative (like Hashtable). This is obviously fundamental to how our client-side ResourceManager works. If we add a value to an array based on a key, we can easily retrieve the value via that same key. The above GetString function also supports placeholders (something we worked hard at to achieve in Part 2). JavaScript allows a dynamic amount of parameters to be passed into a function. GetString() assumes the first parameter is the key of the resource to get, and all subsequent parameters to be placeholder values. For example, to use the "InvalidEmail" resource of "{0} is not a valid email", we'd do:

C#
ResourceManager.GetString("InvalidEmail", email.value);

We'll use a little server-side utility function to dump localized content into the client-side array:

C#
public static void RegisterLocaleResource(string[] keys)
{
   if (keys == null || keys.Length == 0)
   {
      return;
   }
   Page page = HttpContext.Current.Handler as Page;
   if (page == null)
   {
      throw new InvalidOperationException("RegisterResourceManager" + 
                               " must be called from within a page");
   }
   StringBuilder sb = new StringBuilder("<script language="\""JavaScript\">");
   sb.Append(Environment.NewLine);
   foreach (string key in keys)
   {
      sb.Append("ResourceManager.AddString('");
      sb.Append(PrepareStringForJavaScript(key));
      sb.Append("', '");
      sb.Append(PrepareStringForJavaScript(ResourceManager.GetString(key)));
      sb.Append("');");
      sb.Append(Environment.NewLine);
   }
   sb.Append("</script>");
   page.RegisterStartupScript("RM:" + string.Join(":", keys), sb.ToString());
}

The function is called with one or more keys. But instead of returning a localized value, the value is added to the client-side ResourceManager. In other words, before we can use the InvalidEmail resource, we need to call ResourceManager.RegisterLocaleResource("InvalidEmail");. Again, we can pass multiple values if we want, such as ResourceManager.RegisterLocaleResource("InvalidEmail", "InvalidUsername", "Success");. You can call RegisterLocaleResource multiple times as well. This is ideal if your user controls require specific localized content.

LocalizedNoParametersLiteral Haunts Me!

Nothing we've done so far should break existing code. However, in Part 2 we created a LocalizedNoParametersLiteral server control. This was a mistake - to be honest I don't know what I was thinking. The downloadable sample renames LocalizedNoParametersLiteral to LocalizedLiteral and LocalizedLiteral is now LocalizedLabel. If necessary, you can rename them to their old names to avoid broken code, but it's something that I just had to fix this time around.

Conclusion

In this part we covered three major enhancements:

  • The Provider Model and SQL capabilities,
  • The built-in image support, and
  • The JavaScript functionality.

In addition to changing the names of the the LocalizedNoParametersLiteral and LocalizdLiteral around, the downloadable sample has a few other minor code changes. These changes should have no impact on your existing code as they are merely minor improvements.

Aside from being an actual useful library, it's my hope that the Creating Multilingual Websites series made use of strong design practices that you'll be able to make use of in your own code.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here


Written By
Canada Canada
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionMedium Trust Pin
Member 834764227-Apr-12 10:13
Member 834764227-Apr-12 10:13 
QuestionUrgently Help Needed Pin
Member 137496219-Nov-11 5:41
Member 137496219-Nov-11 5:41 
AnswerRe: Urgently Help Needed Pin
Karl Seguin19-Nov-11 6:01
Karl Seguin19-Nov-11 6:01 
QuestionUrgently Help Needed Pin
Member 137496219-Nov-11 5:39
Member 137496219-Nov-11 5:39 
Generalpopulate a grid at runtime with culture Pin
raturi.kamal13-Jun-07 2:04
raturi.kamal13-Jun-07 2:04 
GeneralRe: populate a grid at runtime with culture Pin
Karl Seguin13-Jun-07 14:18
Karl Seguin13-Jun-07 14:18 
GeneralRe: populate a grid at runtime with culture [modified] Pin
raturi.kamal13-Jun-07 20:03
raturi.kamal13-Jun-07 20:03 
QuestionResource files or database Pin
TeachesOfPeaches28-May-07 8:27
TeachesOfPeaches28-May-07 8:27 
AnswerRe: Resource files or database Pin
Karl Seguin28-May-07 14:09
Karl Seguin28-May-07 14:09 
GeneralRe: Resource files or database Pin
TeachesOfPeaches10-Jun-07 9:57
TeachesOfPeaches10-Jun-07 9:57 
GeneralRe: Resource files or database Pin
Karl Seguin10-Jun-07 10:58
Karl Seguin10-Jun-07 10:58 
QuestionUrl Rewrite Path Problem Pin
OzLand1-Mar-07 23:06
OzLand1-Mar-07 23:06 
QuestionRe: Url Rewrite Path Problem Pin
Karl Seguin2-Mar-07 1:58
Karl Seguin2-Mar-07 1:58 
QuestionRe: Url Rewrite Path Problem [modified] Pin
OzLand2-Mar-07 3:58
OzLand2-Mar-07 3:58 
AnswerRe: Url Rewrite Path Problem Pin
Karl Seguin2-Mar-07 14:32
Karl Seguin2-Mar-07 14:32 
AnswerRe: Url Rewrite Path Problem Pin
OzLand5-Mar-07 4:07
OzLand5-Mar-07 4:07 
QuestionLocalizedImages Or HtmlControls Pin
Jeffrey Deflers1-Mar-07 22:26
Jeffrey Deflers1-Mar-07 22:26 
AnswerRe: LocalizedImages Or HtmlControls Pin
OzLand1-Mar-07 23:09
OzLand1-Mar-07 23:09 
GeneralRe: LocalizedImages Or HtmlControls Pin
Jeffrey Deflers2-Mar-07 4:47
Jeffrey Deflers2-Mar-07 4:47 
AnswerRe: LocalizedImages Or HtmlControls [modified] Pin
OzLand2-Mar-07 5:03
OzLand2-Mar-07 5:03 
AnswerRe: LocalizedImages Or HtmlControls Pin
Jeffrey Deflers13-Mar-07 4:56
Jeffrey Deflers13-Mar-07 4:56 
GeneralSwitch to VS 2005 Pin
olivier demers11-Jan-07 4:23
olivier demers11-Jan-07 4:23 
GeneralRe: Switch to VS 2005 Pin
Karl Seguin11-Jan-07 14:41
Karl Seguin11-Jan-07 14:41 
QuestionJavascript Localization Pin
djsputnik8-Oct-06 17:50
djsputnik8-Oct-06 17:50 
AnswerRe: Javascript Localization Pin
Karl Seguin11-Oct-06 1:34
Karl Seguin11-Oct-06 1:34 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.