Internationalization (I18n) and Localization (l10n) Guidelines
Internationalization (i18n) Guidelines for Vassal Developers
Overview
Internationalization, or i18n, is the process of preparing a software project for translation into other languages. VASSAL uses the standard Java "Property file" method to achieve this.
The core i18n process is identifying hard-coded strings in the source code, moving them to a properties file and allocating them a translation key, and replacing the hard-coded string in the source with a call to Resources.getString()
.
A typical part of a properties file looks like this:
# # Dialogs # Dialogs.disable=Don't show this again Dialogs.show_details=Show details Dialogs.hide_details=Hide details
Then, instead of using the code
add(new JLabel("Show Details"));
you use
add(new JLabel(Resource.getString("Dialogs.show_details")));
The Resources.getString()
method will return the appropriate translation for the Dialogs.show_details
key.
However, there are a number of special cases that have to be handled carefully, as discussed below.
The Short Version
- Every source file will need to be checked. As of Summer 2020, most of the Module Manager (MM) and Player are done, but very little of the Editor is done.
- Editor-only keys go in
Editor.properties
. - Module Manager, Player or Shared keys go in
VASSAL.properties
. General
keys are available for commonly-used strings.- Try to avoid creating a new translation key for a String that already has an existing Key.
- Translation keys are generally named
ComponentName.description
orEditor.ComponentName.description
- Add a Translator comment to the properties files where more context is required.
- Consider rewording Strings that are unclear, ambiguous, or use uncommon or overly technical words or phrases.
- Mark lines containing strings not to be translated with
// NON-NLS
- Build whole sentences or phrases to be translated and pass arguments to
getString
. Don't build sentences out of translated fragments. - Remove
": "
from the end of all atrings. The Configurers will be modified to add them if required. - Drop-down list Configurers (e.g.
StringEnumConfigurer
) need to be replaced withTranslatingStringEnumConfigurer
. Read the section on this carefully or get Brent to do it.
The Full Version
Property Files
VASSAL has two separate property files. Editor.properties
and VASSAL.properties
. In general, all keys used only by the Module Editor will be in Editor.properties
file and all other keys will be in the VASSAL.properties
file. Except when allocating new key names and adding them to one of the property files, you do not need to worry about which property file they are in. Resources.getString()
will take care of that for you.
Editor.properties
Editor.properties
contains all translation keys that are used ONLY in the VASSAL Editor. All translation keys in Editor.properties
MUST commence with Editor.
VASSAL.properties
VASSAL.properties
contains all translation keys that are used in the Module Manager or Player, including any translation keys that are also used in the Editor.
Translation Keys
As a general rule, where the same string is used in multiple places in the source code, the string should be replaced by the same Translation key. Each properties file has a set of generic strings with common word, phrases and labels used in VASSAL. These keys should be used where possible. This list is subject to change and will evolve over time, so you should check the properties files after major changes are pushed. See the initial lists of General
strings at the bottom of this page.
Translation keys should generally be named ComponentName.description
for VASSAL keys and Editor.ComponentName.description
for Editor keys. See the existing entries in the property files for the patter. For example:
PlayerRoster.retire=Retire PlayerRoster.allow_another=Switch sides, become an observer, or allow another player to take your side in this game Editor.SpecialDiceButton.component_type=Symbolic Dice Button Editor.SpecialDiceButton.symbols=Symbols
Translator comments
Where you would like to provide further information or context to the translator to assist with the translation, you can include a comment on the line immediately preceding the translation key in the properties file and this will be presented to the translator.
The translator is presented with three pieces of information with which to make the translation:
- The translation key (e.g.
Dialogs.show_details
) - The English text (e.g.
Show details
) - The text of the comment on the preceding line of the properties file if one exists (comments begin with the
#
character).
So if you were to add the following key to the Editor.properties file:
Editor.RandomTextButton.faces=Faces
It is not at all clear to a translator how to translate that. The English word "faces" by itself has many meanings. This might be better:
# A Random Text Button is like a Die with text on the Die Faces instead of numbers. This prompt is for the number of Die Faces and should be translated similarly to the DiceButton section Editor.RandomTextButton.faces=Faces
Although this particular example raises the point of whether we should use better English text for this option.
Consider rewording any English text that is inconsistent, unclear, ambiguous, or uses overly technical words or phrases.
Not-to-be-translated Strings
There are many hard-coded strings in Vassal that MUST NOT be translated. These are typically internal keys for attributes, Command prefixes etc. Generally, these are never displayed to users and cannot be translated as the internal workings of Vassal require the original English strings.
public static final String NAME = "mapName"; // NON-NLS public static final String MARK_MOVED = "markMoved"; // NON-NLS public static final String MARK_UNMOVED_ICON = "markUnmovedIcon"; // NON-NLS public static final String MARK_UNMOVED_TEXT = "markUnmovedText"; // NON-NLS
Most IDE's will have a setting that can be turned on that flags hard-coded string as 'errors' or 'warnings' that need attention. This makes the process of finding strings that need i18n much easier. Adding a comment to the end of the line with the string NON-NLS
in it will mark those string as not to be translated and should turn off the display of errors or warnings for those strings. Again, dependent on IDE settings.
This setting in Intellij (Settings -> Editor -> Inspections -> Java -> Internationalization -> Hard Coded Strings) also ignores strings that have already been internationalized.
Compound Sentences
Avoid creating sentences using Vassal supplied text plus individually translated words, sentence fragments or 'formatting' punctuation. Use parameter replacement instead. If you need to create the string:
Unit [Ger5-6] did something at Moscow.
Don't do this:
String s = Resources.getString("Component.unit")+" ["+getName()+"] "+Resources.getString("Component.did_something_at")+ " " + getLocation(); Component.unit=Unit Component.did_something_at=did something at
Do this:
String s = Resources.getString("Component.unit_did_something", getName(), getLocation()); # %1$s will be a unit name. %2$s will be a map location or city name. Component.unit_did_something=Unit [%1$s] did something at %2$s.
This version gives the translator the full context and gives them the flexibility to change the sentence structure if necessary. In some languages, it would not be possible to create a reasonable translation of the first version. Note the use of the translator comment to give the translator some idea of what sort of text will replace the parameters.
Configurer Prompt Strings
There are many strings used in the Editor that are used as labels in automatically generated configurer dialogs. To get the spacing right, these have a colon and 2 spaces ": "
at the end of the string.
The ": "
and two spaces are not to be included in the string sent to the Translators. Instead, the Configurers will be modified to add them if required (and ultimately remove them). The ": "
should be removed from any existing translation strings that contain it.
Many Configurer fields use standard labels shared between many components, so
descConfig = new StringConfigurer(null, "Description: ", p.description);
should be changed to use the existing General
string:
descConfig = new StringConfigurer(null, Resources.getString("Editor.description_label"), p.description);
Unique Strings should be added to the Editor.properties
files, so
widthConfig = new IntConfigurer(null, "Button Width: ", p.bounds.width);
becomes
widthConfig = new IntConfigurer(null, Resources.getString("Editor.ActionButton.button_width"), p.bounds.width); Editor.ActionButton.button_width=Button Width
Some Configurers are also used by the Player for preferences etc. and have already been translated to include the ": "
. These need to be changed, so
Jlabel pwLabel = new JLabel(Resources.getString("Prefs.password_label")); Prefs.password_label=Password:
becomes
Jlabel pwLabel = new JLabel(Resources.getString("Prefs.password_label")); Prefs.password_label=Password
StringEnumConfigurers
StringEnumConfigurer
instances are used to implement drop-down list selection in component configurers and cannot be directly translated. They need to be replaced with a TranslatingStringEnumConfigurer
and a separate list of translation keys supplied through an updated constructor. There are several different ways StringEnumConfigurer
instances are created and used.
StringEnumConfigurers 1 - Trait usage
For Traits, just directly instantiate an instance of StringEnumCofigurer and pass an array of Translation keys as an extra parameter to the constructor
destInput = new StringEnumConfigurer(null, "Destination: ", new String[] {"Dest1", "Dest2"}); destInput.setValue("Dest1");
becomes
destInput = new StringEnumConfigurer(null, Resources,getString("Editor.ThisTrait.destination", new String[] {"Dest1", "Dest2"}, new String[] {"Editor.ThisTrait.destination_1", "Editor.ThisTrait.destination_1" }); destInput.setValue("Dest1");
StringEnumConfigurers 2 - Local StringEnum class
This is the simplest implementation for AbstractConfigurables. A local static class is specified in getAttributeTypes()
:
public Class<?>[] getAttributeTypes() { return new Class<?>[]{ F.class, String.class,
And the local class overrides StringEnum
:
public static class F extends StringEnum { @Override public String[] getValidValues(AutoConfigurable target) { return new String[]{"Horizontal first", "Vertical first"}; } }
This case is handled by the AutoConfigurer
and a StringEnumConfigurer
is automatically created for you. To update this case, you just need to change the local class to extend TranslatableStringEnum
and add the translation keys as follows:
public static class F extends TranslatableStringEnum { @Override public String[] getValidValues(AutoConfigurable target) { return new String[]{"Horizontal first", "Vertical first"}; } @Override public String[] getI18nKeys(AutoConfigurable target) { return new String[]{"Editor.GridNumbering.horizontal_first", "Editor.GridNumbering.horizontal_first"}; } }
# GridNumbering Editor.GridNumbering.horizontal_first=Horizontal first Editor.GridNumbering.verticalfirst=Vertical first
The values returned by getValidValues()
are the actual values that will stored in the build file. getI18nKeys()
returns the list of Editor keys used to provide the corresponding drop-down list entries.
NOTE: This assumes that "Horizontal first" and "Vertical first" are the actual values stored in the buildfile. See example 4 below for cases where they are not.
StringEnumConfigurers 3 - Local ConfigurerFactory class
This case is similar to case 1, but the local class implements ConfigurerFactory and explicitly constructs a Configurer:
public Class<?>[] getAttributeTypes() { return new Class<?>[] { String.class, SortConfig.class,
public static final String ALPHA = "alpha"; //$NON-NLS-1$ public static final String LENGTHALPHA = "length"; //$NON-NLS-1$ public static final String NUMERIC = "numeric"; //$NON-NLS-1$ public static final String[] SORT_OPTIONS = { ALPHA, LENGTHALPHA, NUMERIC };
public static class SortConfig implements ConfigurerFactory { @Override public Configurer getConfigurer(AutoConfigurable c, String key, String name) { return new StringEnumConfigurer(key, name, SORT_OPTIONS); } }
Convert the local class as follows:
public static final String ALPHA_KEY = "Editor.Inventory.sort_option_alpha"; //$NON-NLS-1$ public static final String LENGTHALPHA_KEY = "Editor.Inventory.sort_option_length"; //$NON-NLS-1$ public static final String NUMERIC_KEY = "Editor.Inventory.sort_option_numeric"; //$NON-NLS-1$ public static final String[] SORT_OPTION_KEYS = { ALPHA_KEY, LENGTHALPHA_KEY, NUMERIC_KEY };
public static class SortConfig implements ConfigurerFactory { @Override public Configurer getConfigurer(AutoConfigurable c, String key, String name) { return new TranslatingStringEnumConfigurer(key, name, SORT_OPTIONS, SORT_OPTION_KEYS); } }
# Inventory # Option to sort strings alphanumerically Editor.Inventory.sort_option_alpha=Alphanumeric Sort # Option to sort by length of string, then alphanumerically Editor.Inventory.sort_option_length=By Length, then Alphanumeric # Option to treat the data as number as sort numerically Editor.Inventory.sort_option_numeric=Numeric Sort
In this case, I have also taken the opportunity to make the drop-down options more meaningful and have provided Translator comments to try and help them understand what is going on.
StringEnumConfigurers 4 - Overridden StringEnumConfigurer class
There are several cases where a ConfigurerFactory
returns a custom class that overrides StringEnumConfigurer
. The custom class needs to be rewritten to override TranslatingStringEnumConfigurer
and pass the Editor keys to the constructor.
StringEnumConfigurers 5 - Alternate values stored to drop-down list values
Check the setAttribute()
method to see if the component is converting the supplied values before saving them. For example, in our example 1 for RegularGridNumbering, setAttribute()
is doing this:
public void setAttribute(String key, Object value) { if (FIRST.equals(key)) { first = ((String) value).charAt(0); }
and is only storing the first letter 'H' or 'V' into the local variables and thus into the buildFile. In this case, the static F
class needs to look like this:
public static class F extends TranslatableStringEnum { @Override public String[] getValidValues(AutoConfigurable target) { return new String[]{"H", "V"}; } @Override public String[] getI18nKeys(AutoConfigurable target) { return new String[]{"Editor.GridNumbering.horizontal_first", "Editor.GridNumbering.horizontal_first"}; } }
The Editor.properties
entries remain unchanged.
Removing Keys and Problem Dialogs
Translation keys which are clearly not used anymore and cannot be found in their supposed component source can be removed.
However, be wary of sets of messages like this:
Error.file_not_found_title=File Not Found Error.file_not_found_heading=File Not Found Error.file_not_found_message=VASSAL could not find the file '%1$s'.
Where there are _title
, _heading
and _message
versions of the same key, your IDE may tell you that these keys are not used and can be removed. However, keys of this form are referenced in the source by the root of the key and the 3 separate keys are referenced programmatically to generate an error dialog. Before removing these sort of keys, you need to search the source for any occurrence of the key root (e.g. in this case Error.file_not_found
).
Translating Error Messages
Error messages written to the Vassal error log file should not be translated.
Additional Considerations for Traits
Default Command Names
Many traits hard-code a default command name into the no-arg constructor like this:
public Delete() { this(ID + :Delete;D", null); // NON-NLS }
We need to provide an appropriate translated default command name as follows:
public Delete() { this(ID + Resources.getString("Editor.Delete.delete") + ";D", null); // NON-NLS }
# Default Delete command Editor.Delete.delete=Delete
Trait Descriptions
Traits provide a brief descriptive name that appears in the Piece Definition dialog:
public String getDescription() { return Resources.getString("Delete"); }
We need to provide a suitable translation:
public String getDescription() { return Resources.getString("Editor.Delete.trait_description"); }
# Description of the Delete trait seen by users Editor.Delete.trait_description=Delete
Module level translation descriptions of translatable elements
At the bottom of each trait is the implementation of the getI18nData which returns a list of the fields in the trait that can be translated at the module level. We need to provide a translated version of these descriptions. So:
public PieceI18nData getI18nData() { return getI18nData(commandName, "Delete Command"); }
becomes:
public PieceI18nData getI18nData() { return getI18nData(commandName, Resources.getString("Editor.Delete.delete_command_description")); }
# Delete Command Name description seen by module translators Editor.Delete.delete_command_description=Delete Command