Introduction
This app allows a user to select a base locale and any other locale or locales to work on. Then, it displays the name and value of the resources in a table. Users can keep track of which entries need to be translated, or simply work on this table to fill all the blanks. The changes will be saved with UTF8 encoding.
It supports two types for resource files:
- *.properties files for Java or Flex
- *.resx files for .NET
Background
A problem I have been facing recently during Flex development is that the en_US base locale files change from time to time as new strings are added to the code, and it is hard to keep the files for other locales in sync. I couldn't find any simple and free tool like this on the web, so I decided to develop it by myself.
How to Use the LocaleManager
The assumptions are that users follow the common practices for locale folders: use a separate folder for each locale. So, they normally look like:
locale-
|--- en_US - resource files in English
|--- de - resource files in German
|--- fr - resource files in French
It is very easy to use the locale manager. It is a standalone application that runs on Windows.
To use it, just follow the screen captures.
- Start the program. Then, select the base locale directory, e.g., en_us as in screenshot 1.
- After clicking OK, the base locale name will be shown in red color on the dialog. Also, the sibling sub-directories will be displayed in the list box on the left as the existing locale. As I am using Subversion, the svn directory also shows up, and that shouldn't be selected as a target locale. See screenshot 2.
- After selecting the locales to work on, click Load and the worksheet will be shown. My test input files only have three lines, so it doesn't look impressive. This window is resizable. This worksheet form shows the resource name, its value in the base locale, and the values in the selected locales. It is very easy to see which ones are not translated yet.
The target locale file will be reorganized to use the same resource entries as in the base. Entries that exist in the base but are not in the target will be added to the target locale with a blank value. Entries that exist in the target but not in the base will be taken out and saved to a Mismatch_timestamp.log file located in the same folder as the EXE file.
Click Save Changes before closing the window, and changes made will be saved to the target file. The base file will not be touched.
Using the Code
I. Program Structure - Using Model to Store Data
There are two forms in the code: MainForm
and WorkSheetForm
. MainForm
is the one to ask the user to select the base locale directory by file browsing. Then, it displays all the sibling subdirectories of the base directory.
WorkSheetForm
will show after the user selects one or more target locales and clicks the Load button. The program will dynamically add columns to the DataGrid
depending on how many locales are selected.
This program uses the MVC (Model-View-Controller) pattern to pass data around the UI. Application data is stored in Global.cs, the Model. So, MainForm.cs passes data such as BaseDir, RootDir, and Locales to Global.cs, and WorkSheetForm.cs can go pick it up over there.
II. Using Directoryinfo and Folderbrowserdialog Classes
FolderBrowserDialog
is used instead of an OpenFileDialog
so that only folders are shown while user tries to find the base locale directory.
.NET's DirectoryInfo
class is very handy and you can retrieve all different information about a directory or a file. The MainForm.cs uses this class to get the base directory and the root locale directory. The fillLocaleList()
function finds all the sibling subdirectories and add them to the list box.
private void m_browse_Click(object sender, EventArgs e)
{
m_baseDirBrowserDlg.SelectedPath = Environment.CurrentDirectory;
if (m_baseDirBrowserDlg.ShowDialog(this) != DialogResult.OK)
return;
Global.BaseDir = new DirectoryInfo(m_baseDirBrowserDlg.SelectedPath);
Global.RootDir = Global.BaseDir.Parent;
m_baseLocale.Text = Global.BaseDir.Name;
fillLocaleList();
}
private void fillLocaleList()
{
m_allLocales.Items.Clear();
m_selectedLocale.Items.Clear();
DirectoryInfo[] subs = Global.RootDir.GetDirectories();
foreach (DirectoryInfo sub in subs)
{
String dirName = sub.Name;
if (dirName != Global.BaseDir.Name)
m_allLocales.Items.Add(dirName);
}
if (m_allLocales.Items.Count > 0)
{
m_add.Enabled = true;
m_addAll.Enabled = true;
m_allLocales.SelectedIndex = 0;
}
else
MessageBox.Show("No sibling folders were found for the base locale.");
}
The code will enable buttons only when a user finishes the required steps first. This is very easy to do but will help the user a lot and make the application friendly.
III. Dynamically Add Columns to a DataGrid
If the datagrid you want to display always has the same number of columns and the headers remain the same, you can just configure the datagrid
through property settings. However, sometimes the number of columns and headings depends on user's input, we need to dynamically add columns to the datagrid.
The following code shows how columns are added to the Datagrid
on Worksheet form based on which target locales user selects. Column index comes from enumeration of columns:
public enum Columns
{
No = 0, File, Name, Base
};
private void WorkSheetForm_Load(object sender, EventArgs e)
{
FileInfo[] files = Global.BaseDir.GetFiles("*"+ Global.Extension);
...
...
m_grid.ColumnCount = Global.Locales.Length + (int)Columns.Base + 1;
_numOfCols = m_grid.ColumnCount;
m_grid.Columns[(int)Columns.No].Name = "no.";
m_grid.Columns[(int)Columns.No].ValueType = typeof(int);
m_grid.Columns[(int)Columns.File].Name = "file";
m_grid.Columns[(int)Columns.Name].Name = "name";
m_grid.Columns[(int)Columns.Base].Name = Global.BaseDir.Name;
int i = (int)Columns.Base + 1;
foreach (string locale in Global.Locales)
m_grid.Columns[i++].Name = locale;
...
}
When the column "no.
" is added, I set its ValueType
to int
, otherwise it is going to be string
by default. This matters when user clicks the header to do a sort. If the value type is string
, the sorted result will be "1, 11, 2, 21, ..." instead of "1, 2, 3, ...".
m_grid.Columns[(int)Columns.No].Name = "no.";
m_grid.Columns[(int)Columns.No].ValueType = typeof(int);
IV. Dynamically Add Rows to the Datagrid
The function AddRowToGrid()
shows how to add a new row to the datagrid. The function AddToRow()
shows how to fill out cells or access cell values in an existing row.
private void AddRowToGrid(string file, int i, string name, string value)
{
string[] row;
row = new string[_numOfCols];
row[(int)Columns.No] = i.ToString();
row[(int)Columns.File] = file;
row[(int)Columns.Name] = name.Trim();
row[(int)Columns.Base] = value.Trim();
m_grid.Rows.Add(row);
}
private void AddToRow(int i, int localeIndex, string value)
{
if (null!= value && i>=0)
m_grid.Rows[i].Cells[getColumnNo(localeIndex)].Value = value;
}
private int getColumnNo(int localeIndex)
{
return (int)Columns.Base + localeIndex + 1;
}
V. Parsing *.resx Files using XMLTextReader
The *.properties files are loaded by StreamReader
, and the resource name/value pairs are read by String.Split
.
tokens = line.Split('=');
The *.resx files are loaded by an XmlTextReader
. The *.resx file entries look like this:
="1.0" ="utf-8"
<root>
-->
<data name="GoodEvening" xml:space="preserve">
<value>Good Evening</value>
<comment>Evening</comment>
</data>
<data name="GoodMorning" xml:space="preserve">
<value>Good Morning</value>
<comment>Morning</comment>
</data>
<data name="Hello" xml:space="preserve">
<value>Hello</value>
<comment>Greetings</comment>
</data>
</root>
The XML parsing needs to parse element attributes and texts. The parsing code is as follows:
using (XmlTextReader rd = new XmlTextReader(path))
{
XmlParsingSteps step = XmlParsingSteps.ParsingName;
string name = "";
string value = "";
while (rd.Read())
{
switch (rd.NodeType)
{
case XmlNodeType.Element:
if (step == XmlParsingSteps.ParsingName && rd.Name == "data")
{
while (rd.MoveToNextAttribute())
{
if (rd.Name == "name")
{
name = rd.Value;
step = XmlParsingSteps.ParsingValueTag;
break;
}
}
}
else if (step == XmlParsingSteps.ParsingValueTag &&
rd.Name == "value")
{
step = XmlParsingSteps.ParsingValue;
}
break;
case XmlNodeType.Text:
if (step == XmlParsingSteps.ParsingValue)
{
value = rd.Value;
if (isBase)
{
AddRowToGrid(file, rowIndex, name, value);
rowIndex++;
}
else
{
rowIndex = findRowNo(file, name.Trim());
if (rowIndex < 0)
{
Global.Log(Global.MismatchLog, name + "-" + value);
}
else
AddToRow(rowIndex, localeIndex, value);
}
step = XmlParsingSteps.ParsingName;
name = "";
value = "";
}
break;
default:
break;
}
}
}
Points of Interest
The thing I like about this app is that the WorkSheetForm
resizes so smoothly. Before, I had the combo box and a Save button above the DataGridView
, and it was difficult to set the Anchor
and Dock
properties to make the DataGrid
grow when the user resizes the window. At least, the column width didn't change.
Then later, I just added a menu and added the Save option to the menu. The ComboBox
was just dropped over there and it still works fine even though it is sitting on the menu bar. Just set the DataGridView
's Dock
property to "Fill
" and it will fill into the space of the whole app.
The only glitch I see in this application is that after typing anything to a cell in the DataGrid
, you have to click somewhere else before the value is updated for that cell, even though on screen, it shows the words you just typed. So, if you continue to edit, you will be fine. Only the last entry may not be saved correctly if you do not click somewhere else before clicking Save. I think it has something to do with the EditMode
property of the DataGrid
. In my program, the setting is EditOnEnter
. I tried to set it to different things, but didn't see it solve this issue. Anyway, I will keep trying to find an answer for this.
History
- 2/20/09
- Initial version to support *.properties files
- 2/26/09
- Added support for *.resx load and view
- Save hasn't been implemented for *.resx files yet
- 3/8/09
- 7/31/09
- Added LocaleManager_1.3_Executables.zip