/**
* Copyright (C) 2002-2012 The FreeCol Team
*
* This file is part of FreeCol.
*
* FreeCol is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* FreeCol is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with FreeCol. If not, see <http://www.gnu.org/licenses/>.
*/
package net.sf.freecol.client.gui.i18n;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Locale;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import javax.swing.UIManager;
import net.sf.freecol.common.io.FreeColDirectories;
import net.sf.freecol.common.io.FreeColModFile;
import net.sf.freecol.common.io.Mods;
import net.sf.freecol.common.model.AbstractUnit;
import net.sf.freecol.common.model.FreeColObject;
import net.sf.freecol.common.model.Player;
import net.sf.freecol.common.model.Region.RegionType;
import net.sf.freecol.common.model.StringTemplate;
import net.sf.freecol.common.model.StringTemplate.TemplateType;
import net.sf.freecol.common.model.Unit;
import net.sf.freecol.common.model.UnitType;
import net.sf.freecol.common.option.Option;
/**
* <p>Represents a collection of messages in a particular locale.</p>
*
* <p>The individual messages are read from property files in the
* <code>data/strings</code> directory. The property files are called
* "FreeColMessages[_LANGUAGE[_COUNTRY[_VARIANT]]].properties", where
* LANGUAGE should be an ISO 639-2 or ISO 639-3 language code, COUNTRY
* should be an ISO 3166-2 country code, and VARIANT is an arbitrary
* string. The encoding of the property files is UTF-8. Since the Java
* Properties class is unable to handle UTF-8 directly, this class
* uses its own implementation.</p>
*
* <p>The individual messages may include variables, which must be
* delimited by percent characters (e.g. "%nation%"), and will be
* replaced when the message is formatted. Furthermore, the messages
* may include choice formats consisting of a tag followed by a colon
* (":"), a selector and one or several choices separated from the
* selector and each other by pipe characters ("|"). The entire choice
* format must be enclosed in double brackets ("{{" and "}}",
* respectively).</p>
*
* <p>Each choice must consist of a key and a value separated by an
* equals character ("="), unless it is a variable, in which case the
* variable must resolve to another choice format. The selector may
* also be a variable. If the selector is omitted, then one of the
* choices should use the key "default". Choice formats may be
* nested.</p>
*
* <pre>
* key1=%colony% tuottaa tuotetta {{tag:acc|%goods%}}.
* key2={{plural:%amount%|one=ruoka|other=ruokaa|default={{tag:|acc=viljaa|default=Vilja}}}}
* key3={{tag:|acc=viljaa|default={{plural:%amount%|one=ruoka|other=ruokaa|default=Ruoka}}}}
* </pre>
*
* <p>This class is NOT thread-safe. (CO: I cannot find any place that
* really has a problem)</p>
*
*/
public class Messages {
private static final Logger logger = Logger.getLogger(Messages.class.getName());
public static final String FILE_PREFIX = "FreeColMessages";
public static final String FILE_SUFFIX = ".properties";
private static final String[] DESCRIPTION_KEYS = new String[] {
".description", ".shortDescription", ".name"
};
private static Map<String, String> messageBundle =
new HashMap<String, String>();
/**
* A map with Selector values and the tag keys used in choice
* formats.
*/
private static Map<String, Selector> tagMap =
new HashMap<String, Selector>();
static {
tagMap.put("turn", new TurnSelector());
}
/**
* Returns the Selector with the given tag.
*
* @param tag a <code>String</code> value
* @return a <code>Selector</code> value
*/
private static Selector getSelector(String tag) {
return tagMap.get(tag.toLowerCase(Locale.US));
}
/**
* Set the grammatical number rule.
*
* @param number a <code>Number</code> value
*/
public static void setGrammaticalNumber(Number number) {
tagMap.put("plural", number);
}
/**
* Set the resource bundle for the given locale
*
* @param locale
*/
public static void setMessageBundle(Locale locale) {
if (locale == null) {
throw new NullPointerException("Parameter locale must not be null");
} else {
if (!Locale.getDefault().equals(locale)) {
Locale.setDefault(locale);
}
setMessageBundle(locale.getLanguage(), locale.getCountry(), locale.getVariant());
}
}
/**
* Set the resource bundle to the given locale
*
* @param language The language for this locale.
* @param country The language for this locale.
* @param variant The variant for this locale.
*/
private static void setMessageBundle(String language, String country, String variant) {
messageBundle = new HashMap<String, String>();
List<String> filenames = FreeColModFile.getFileNames(FILE_PREFIX, FILE_SUFFIX, language, country, variant);
if (!NumberRules.isInitialized()) {
// attempt to read grammatical rules
File stringDirectory = FreeColDirectories.getI18nDirectory();
if (stringDirectory.exists()) {
File cldr = new File(stringDirectory, "plurals.xml");
if (cldr.exists()) {
try {
FileInputStream in = new FileInputStream(cldr);
NumberRules.load(in);
in.close();
} catch(Exception e) {
logger.warning("Failed to read CLDR rules: "
+ e.toString());
}
} else {
logger.warning("Could not find CLDR rules: "
+ cldr.getPath());
}
} else {
logger.warning("Could not find string directory: "
+ stringDirectory.getPath());
}
}
setGrammaticalNumber(NumberRules.getNumberForLanguage(language));
for (String fileName : filenames) {
File resourceFile = new File(FreeColDirectories.getI18nDirectory(), fileName);
loadResources(resourceFile);
logger.finest("Loaded message bundle " + fileName + " from messages.");
}
List<FreeColModFile> allMods = new ArrayList<FreeColModFile>();
allMods.addAll(Mods.getAllMods());
allMods.addAll(Mods.getRuleSets());
for (FreeColModFile fcmf : allMods) {
for (String fileName : filenames) {
try {
InputStream is = fcmf.getInputStream(fileName);
loadResources(is);
logger.finest("Loaded message bundle " + fileName + " from "
+ fcmf.getId() + ".");
} catch (IOException e) {
logger.fine("No message bundle " + fileName + " in "
+ fcmf.getId() + ".");
}
}
}
}
/**
* Returns the text mapping for a particular ID in the default locale message bundle.
* Returns the key as the value if there is no mapping found!
*
* @param messageId The key of the message to find
* @return String text mapping or the key
*/
public static String message(String messageId) {
// Check that all the values are correct.
if (messageId == null) {
throw new NullPointerException("Message ID must not be null!");
}
if (messageBundle == null) {
setMessageBundle(Locale.getDefault());
}
// return key as value if there is no mapping found
String message = messageBundle.get(messageId);
if (message == null) {
return messageId;
}
// otherwise replace variables in the text
message = replaceChoices(message, null);
return message.trim();
}
/**
* Replace all choice formats in the given string, using keys and
* replacement values from the given template, which may be null.
*
* A choice format is enclosed in double brackets and consists of
* a tag, followed by a colon, followed by an optional selector,
* followed by a pipe character, followed by one or several
* choices separated by pipe characters. If there is only one
* choice, it must be a message id or a variable. Otherwise, each
* choice consists of a key and a value separated by an assignment
* character. Example: "{{tag:selector|key1=val1|key2=val2}}".
*
* @param input a <code>String</code> value
* @param template a <code>StringTemplate</code> value
* @return a <code>String</code> value
*/
private static String replaceChoices(String input, StringTemplate template) {
int openChoice = 0;
int closeChoice = 0;
int highWaterMark = 0;
StringBuilder result = new StringBuilder();
while ((openChoice = input.indexOf("{{", highWaterMark)) >= 0) {
result.append(input.substring(highWaterMark, openChoice));
closeChoice = findMatchingBracket(input, openChoice + 2);
if (closeChoice < 0) {
// no closing brackets found
logger.warning("Mismatched brackets: " + input);
return result.toString();
}
highWaterMark = closeChoice + 2;
int colonIndex = input.indexOf(":", openChoice + 2);
if (colonIndex < 0 || colonIndex > closeChoice) {
logger.warning("No tag found: " + input);
continue;
}
String tag = input.substring(openChoice + 2, colonIndex);
int pipeIndex = input.indexOf("|", colonIndex + 1);
if (pipeIndex < 0 || pipeIndex > closeChoice) {
logger.warning("No choices found: " + input);
continue;
}
String selector = input.substring(colonIndex + 1, pipeIndex);
if ("".equals(selector)) {
selector = "default";
} else if (selector.startsWith("%") && selector.endsWith("%")) {
if (template == null) {
selector = "default";
} else {
StringTemplate replacement = template.getReplacement(selector);
if (replacement == null) {
logger.warning("Failed to find replacement for " + selector);
continue;
} else {
selector = message(replacement);
Selector taggedSelector = getSelector(tag);
if (taggedSelector != null) {
selector = taggedSelector.getKey(selector, input);
}
}
}
} else {
Selector taggedSelector = getSelector(tag);
if (taggedSelector != null) {
selector = taggedSelector.getKey(selector, input);
}
}
int keyIndex = input.indexOf(selector, pipeIndex + 1);
if (keyIndex < 0 || keyIndex > closeChoice) {
// key not found, choice might be a key itself
String otherKey = input.substring(pipeIndex + 1, closeChoice);
if (otherKey.startsWith("%") && otherKey.endsWith("%")
&& template != null) {
StringTemplate replacement = template.getReplacement(otherKey);
if (replacement == null) {
logger.warning("Failed to find replacement for " + otherKey);
continue;
} else if (replacement.getTemplateType() == TemplateType.KEY) {
otherKey = messageBundle.get(replacement.getId());
keyIndex = otherKey.indexOf("{{");
if (keyIndex < 0) {
// not a choice format
result.append(otherKey);
} else {
keyIndex = otherKey.indexOf(selector, keyIndex);
if (keyIndex < 0) {
logger.warning("Failed to find key " + selector + " in replacement "
+ replacement.getId());
continue;
} else {
result.append(getChoice(otherKey, selector));
}
}
} else {
logger.warning("Choice substitution attempted, but template type was "
+ replacement.getTemplateType());
continue;
}
} else if (containsKey(otherKey)) {
otherKey = getChoice(messageBundle.get(otherKey), selector);
result.append(otherKey);
} else {
logger.warning("Unknown key or untagged choice: '" + otherKey
+ "', selector was '" + selector
+ "', trying 'default' instead");
int defaultStart = otherKey.indexOf("default=");
if (defaultStart >= 0) {
defaultStart += 8;
int defaultEnd = otherKey.indexOf('|', defaultStart);
String defaultChoice;
if (defaultEnd < 0) {
defaultChoice = otherKey.substring(defaultStart);
} else {
defaultChoice = otherKey.substring(defaultStart, defaultEnd);
}
result.append(defaultChoice);
} else {
logger.warning("No default choice found.");
continue;
}
}
} else {
int start = keyIndex + selector.length() + 1;
int replacementIndex = input.indexOf("|", start);
int nextOpenIndex = input.indexOf("{{", start);
if (nextOpenIndex >= 0 && nextOpenIndex < replacementIndex) {
replacementIndex = input.indexOf("|", findMatchingBracket(input, nextOpenIndex + 2) + 2);
}
int end = (replacementIndex < 0 || replacementIndex > closeChoice)
? closeChoice : replacementIndex;
String replacement = input.substring(start, end);
if (replacement.indexOf("{{") < 0) {
result.append(replacement);
} else {
result.append(replaceChoices(replacement, template));
}
}
}
result.append(input.substring(highWaterMark));
return result.toString();
}
/**
* Return the choice tagged with the given key, or null, if the
* given input string does not contain the key.
*
* @param input a <code>String</code> value
* @param key a <code>String</code> value
* @return a <code>String</code> value
*/
private static String getChoice(String input, String key) {
int keyIndex = input.indexOf(key);
if (keyIndex < 0) {
return null;
} else {
int start = keyIndex + key.length() + 1;
int end = input.indexOf("|", start);
if (end < 0) {
end = input.indexOf("}}", start);
if (end < 0) {
logger.warning("Failed to find end of choice for key " + key
+ " in input " + input);
return null;
}
}
return input.substring(start, end);
}
}
/**
* Return the index of the matching pair of brackets, or -1 if
* none is found.
*
* @param input a <code>String</code> value
* @param start an <code>int</code> value
* @return an <code>int</code> value
*/
private static int findMatchingBracket(String input, int start) {
char last = 0;
int level = 0;
for (int index = start; index < input.length(); index++) {
switch(input.charAt(index)) {
case '{':
if (last == '{') {
last = 0;
level++;
} else {
last = '{';
}
break;
case '}':
if (last == '}') {
if (level == 0) {
return index - 1;
} else {
last = 0;
level--;
}
} else {
last = '}';
}
break;
}
}
// found no matching bracket
return -1;
}
/**
* Localizes a StringTemplate.
*
* @param template a <code>StringTemplate</code> value
* @return a <code>String</code> value
*/
public static String message(StringTemplate template) {
String result = "";
switch (template.getTemplateType()) {
case LABEL:
if (template.getReplacements() == null
|| template.getReplacements().isEmpty()) {
return message(template.getId());
} else {
for (StringTemplate other : template.getReplacements()) {
result += template.getId() + message(other);
}
if (result.length() > template.getId().length()) {
return result.substring(template.getId().length());
} else {
logger.warning("incorrect use of template " + template.toString());
return result;
}
}
case TEMPLATE:
if (containsKey(template.getId())) {
result = messageBundle.get(template.getId());
} else if (template.getDefaultId() != null) {
result = messageBundle.get(template.getDefaultId());
}
result = replaceChoices(result, template);
for (int index = 0; index < template.getKeys().size(); index++) {
result = result.replace(template.getKeys().get(index),
message(template.getReplacements().get(index)));
}
return result;
case KEY:
String key = messageBundle.get(template.getId());
if (key == null) {
return template.getId();
} else {
return replaceChoices(key, null);
}
case NAME:
default:
return template.getId();
}
}
/**
* Returns true if the message bundle contains the given key.
*
* @param key a <code>String</code> value
* @return a <code>boolean</code> value
*/
public static boolean containsKey(String key) {
if (messageBundle == null) {
setMessageBundle(Locale.getDefault());
}
return (messageBundle.get(key) != null);
}
/**
* Returns the preferred key if it is contained in the message
* bundle and the default key otherwise. This should be used to
* select the most specific message key available.
*
* @param preferredKey a <code>String</code> value
* @param defaultKey a <code>String</code> value
* @return a <code>String</code> value
*/
public static String getKey(String preferredKey, String defaultKey) {
if (containsKey(preferredKey)) {
return preferredKey;
} else {
return defaultKey;
}
}
public static String getName(FreeColObject object) {
return message(object.getId() + ".name");
}
public static String getDescription(FreeColObject object) {
return message(object.getId() + ".description");
}
public static String getShortDescription(FreeColObject object) {
return message(object.getId() + ".shortDescription");
}
public static String getName(Option<?> object) {
return message(object.getId() + ".name");
}
public static String getDescription(Option<?> object) {
for (String suffix : DESCRIPTION_KEYS) {
String key = object.getId() + suffix;
if (containsKey(key)) {
return message(key);
}
}
return object.getId();
}
public static String getShortDescription(Option<?> object) {
return message(object.getId() + ".shortDescription");
}
/**
* Returns the name of a unit in a human readable format. The
* label consists of up to three items: If the unit has a role
* other than the unit type's default role, the current role, the
* proper name of the unit and the unit's type. Otherwise, the
* unit's type, the proper name of the unit, and additional
* information about gold (in the case of treasure trains), or
* equipment.
*
* @param unit an <code>Unit</code> value
* @return A label to describe the given unit
*/
public static StringTemplate getLabel(Unit unit) {
String typeKey = null;
String infoKey = null;
if (unit.canCarryTreasure()) {
typeKey = unit.getType().getNameKey();
infoKey = Integer.toString(unit.getTreasureAmount());
} else {
String key = (unit.getRole() == Unit.Role.DEFAULT) ? "name"
: unit.getRole().toString().toLowerCase();
String messageID = unit.getType().getId() + "." + key;
if (containsKey(messageID)) {
typeKey = messageID;
if ((unit.getEquipment() == null || unit.getEquipment().isEmpty()) &&
unit.getType().getDefaultEquipmentType() != null) {
infoKey = unit.getType().getDefaultEquipmentType().getId() + ".none";
}
} else {
typeKey = "model.unit.role." + key;
infoKey = unit.getType().getNameKey();
}
}
StringTemplate result = StringTemplate.label(" ")
.add(typeKey);
if (unit.getName() != null) {
result.addName(unit.getName());
}
if (infoKey != null) {
result.addStringTemplate(StringTemplate.label("")
.addName("(")
.add(infoKey)
.addName(")"));
}
return result;
}
/**
* Returns the name of a unit in a human readable format. The return value
* can be used when communicating with the user.
*
* @param someType an <code>UnitType</code> value
* @param someRole a <code>Role</code> value
* @param count an <code>int</code> value
* @return The given unit type as a String
*/
public static String getLabel(UnitType someType, Unit.Role someRole, int count) {
String key = someRole.toString().toLowerCase();
if (someRole == Unit.Role.DEFAULT) {
key = "name";
}
String messageID = someType.getId() + "." + key;
if (containsKey(messageID)) {
return message(messageID);
} else {
return message(StringTemplate.template("model.unit." + key + ".name")
.addAmount("%number%", count)
.addName("%unit%", someType));
}
}
/**
* Returns the name of a unit in a human readable format. The return value
* can be used when communicating with the user.
*
* @param unit an <code>AbstractUnit</code> value
* @return The given unit type as a String
*/
public static String getLabel(AbstractUnit unit) {
String key = unit.getRole().toString().toLowerCase();
if (unit.getRole() == Unit.Role.DEFAULT) {
key = "name";
}
String messageID = unit.getId() + "." + key;
if (containsKey(messageID)) {
return message(messageID);
} else {
return message(StringTemplate.template("model.unit." + key + ".name")
.addName("%unit%", unit));
}
}
/**
* Returns a string describing the given stance.
*
* @param stance The stance.
* @return A matching string.
*/
public static String getStanceAsString(Player.Stance stance) {
return message("model.stance." + stance.toString().toLowerCase());
}
/**
* Gets a string describing the number of turns left for a colony
* to finish building something.
*
* @param turns the number of turns left
* @return A descriptive string.
*/
public static String getTurnsText(int turns) {
return (turns == FreeColObject.UNDEFINED)
? message("notApplicable.short")
: (turns >= 0) ? Integer.toString(turns)
: ">" + Integer.toString(-turns);
}
public static String getNewLandName(Player player) {
if (player.getNewLandName() == null) {
return message(player.getNationID() + ".newLandName");
} else {
return player.getNewLandName();
}
}
/**
* Creates a unique region name by fetching a new default name
* from the list of default names if possible.
*
* @param player <code>Player</code>
* @param regionType a <code>RegionType</code> value
* @return a <code>String</code> value
*/
public static String getDefaultRegionName(Player player, RegionType regionType) {
net.sf.freecol.common.model.Map map = player.getGame().getMap();
int index = player.getNameIndex(regionType.getNameIndexKey());
if (index < 1) index = 1;
String prefix = player.getNationID() + ".region."
+ regionType.toString().toLowerCase(Locale.US) + ".";
String name;
do {
name = null;
if (containsKey(prefix + Integer.toString(index))) {
name = Messages.message(prefix + Integer.toString(index));
index++;
}
} while (name != null && map.getRegionByName(name) != null);
player.setNameIndex(regionType.getNameIndexKey(), index);
if (name == null) {
do {
name = message(StringTemplate.template("model.region.default")
.addStringTemplate("%nation%", player.getNationName())
.add("%type%", "model.region." + regionType.toString().toLowerCase() + ".name")
.addAmount("%index%", index));
index++;
} while (map.getRegionByName(name) != null);
}
return name;
}
/**
* Collects all the names with a given prefix.
*
* @param prefix The prefix to check.
* @param names A list to fill with the names found.
*/
private static void collectNames(String prefix, List<String> names) {
String name;
int i = 0;
while (Messages.containsKey(name = prefix + Integer.toString(i))) {
names.add(Messages.message(name));
i++;
}
}
/**
* Gets a list of settlement names and a fallback prefix for a player.
*
* @param player The <code>Player</code> to get names for.
* @return A list of settlement names, with the first being the
* fallback prefix.
*/
public static List<String> getSettlementNames(Player player) {
List<String> names = new ArrayList<String>();
collectNames(player.getNationID() + ".settlementName.", names);
// Try the spec-qualified version.
if (names.isEmpty()) {
collectNames(player.getNationID() + ".settlementName."
+ player.getSpecification().getId() + ".", names);
}
return names;
}
/**
* Gets a list of ship names and a fallback prefix for a player.
*
* @param player The <code>Player</code> to get names for.
* @return A list of ship names, with the first being the fallback prefix.
*/
public static List<String> getShipNames(Player player) {
final String prefix = player.getNationID() + ".ship.";
List<String> names = new ArrayList<String>();
// Fallback prefix first
names.add(message("Ship"));
// Collect the rest
collectNames(prefix, names);
return names;
}
/**
* Loads a new resource file into the current message bundle.
*
* @param resourceFile
*/
public static void loadResources(File resourceFile) {
if ((resourceFile != null) && resourceFile.exists()
&& resourceFile.isFile() && resourceFile.canRead()) {
try {
loadResources(new FileInputStream(resourceFile));
} catch (Exception e) {
logger.warning("Unable to load resource file " + resourceFile.getPath());
}
}
}
/**
* Loads a new resource file into the current message bundle.
*
* @param is an <code>InputStream</code> value
*/
public static void loadResources(InputStream is) {
try {
InputStreamReader inputReader = new InputStreamReader(is, "UTF-8");
BufferedReader in = new BufferedReader(inputReader);
String line = null;
while((line = in.readLine()) != null) {
line = line.trim();
int index = line.indexOf('#');
if (index == 0) {
continue;
}
index = line.indexOf('=');
if (index > 0) {
String key = line.substring(0, index).trim();
String value = line.substring(index + 1).trim()
.replace("\\n", "\n").replace("\\t", "\t");
messageBundle.put(key, value);
if (key.startsWith("FileChooser.")) {
UIManager.put(key, value);
}
}
}
} catch (Exception e) {
logger.warning("Unable to load resources from input stream.");
}
}
/**
* Breaks a line between two words. The breaking point
* is as close to the center as possible.
*
* @param string The line for which we should determine a
* breaking point.
* @return The best breaking point or <code>-1</code> if there
* are none.
*/
public static int getBreakingPoint(String string) {
int center = string.length() / 2;
for (int offset = 0; offset < center; offset++) {
if (string.charAt(center + offset) == ' ') {
return center + offset;
} else if (string.charAt(center - offset) == ' ') {
return center - offset;
}
}
return -1;
}
}