Internationalization (I18n) and Localization (l10n) Guidelines

From Vassal


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 or Editor.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 with TranslatingStringEnumConfigurer. 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:

  1. The translation key (e.g. Dialogs.show_details)
  2. The English text (e.g. Show details)
  3. 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 AutoConfiguer 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