/*
* Copyright (C) 2009 JavaRosa
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.openrosa.client.jr.core.services.locale;
import java.io.IOException;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Vector;
import org.openrosa.client.java.io.DataInputStream;
import org.openrosa.client.java.io.DataOutputStream;
import org.openrosa.client.jr.core.util.NoLocalizedTextException;
import org.openrosa.client.jr.core.util.OrderedHashtable;
import org.openrosa.client.jr.core.util.UnregisteredLocaleException;
import org.openrosa.client.jr.core.util.externalizable.DeserializationException;
import org.openrosa.client.jr.core.util.externalizable.ExtUtil;
import org.openrosa.client.jr.core.util.externalizable.ExtWrapList;
import org.openrosa.client.jr.core.util.externalizable.ExtWrapListPoly;
import org.openrosa.client.jr.core.util.externalizable.ExtWrapMap;
import org.openrosa.client.jr.core.util.externalizable.ExtWrapNullable;
import org.openrosa.client.jr.core.util.externalizable.Externalizable;
import org.openrosa.client.jr.core.util.externalizable.PrototypeFactory;
/**
* The Localizer object maintains mappings for locale ID's and Object
* ID's to the String values associated with them in different
* locales.
*
* @author Drew Roos/Clayton Sims
*
*/
public class Localizer implements Externalizable {
private Vector locales; /* Vector<String> */
private OrderedHashtable localeResources; /* String -> Vector<LocaleDataSource> */
private OrderedHashtable currentLocaleData; /* Hashtable{ String -> String } */
private String defaultLocale;
private String currentLocale;
private boolean fallbackDefaultLocale;
private boolean fallbackDefaultForm;
private Vector observers;
/**
* Default constructor. Disables all fallback modes.
*/
public Localizer () {
this(false, false);
}
/**
* Full constructor.
*
* @param fallbackDefaultLocale If true, search the default locale when no translation for a particular text handle
* is found in the current locale.
* @param fallbackDefaultForm If true, search the default text form when no translation is available for the
* specified text form ('long', 'short', etc.). Note: form is specified by appending ';[form]' onto the text ID.
*/
public Localizer (boolean fallbackDefaultLocale, boolean fallbackDefaultForm) {
localeResources = new OrderedHashtable();
currentLocaleData = new OrderedHashtable();
locales = new Vector();
defaultLocale = null;
currentLocale = null;
observers = new Vector();
this.fallbackDefaultLocale = fallbackDefaultLocale;
this.fallbackDefaultForm = fallbackDefaultForm;
}
public boolean equals (Object o) {
if (o instanceof Localizer) {
Localizer l = (Localizer)o;
//TODO: Compare all resources
return (ExtUtil.equals(locales, locales) &&
ExtUtil.equals(localeResources, l.localeResources) &&
ExtUtil.equals(defaultLocale, l.defaultLocale) &&
ExtUtil.equals(currentLocale, l.currentLocale) &&
fallbackDefaultLocale == l.fallbackDefaultLocale &&
fallbackDefaultForm == l.fallbackDefaultForm);
} else {
return false;
}
}
/**
* Get default locale fallback mode
*
* @return default locale fallback mode
*/
public boolean getFallbackLocale () {
return fallbackDefaultLocale;
}
/**
* Get default form fallback mode
*
* @return default form fallback mode
*/
public boolean getFallbackForm () {
return fallbackDefaultForm;
}
/* === INFORMATION ABOUT AVAILABLE LOCALES === */
/**
* Create a new locale (with no mappings). Do nothing if the locale is already defined.
*
* @param locale Locale to add. Must not be null.
* @return True if the locale was not already defined.
* @throws NullPointerException if locale is null
*/
public boolean addAvailableLocale (String locale) {
if (hasLocale(locale)) {
return false;
} else {
locales.addElement(locale);
localeResources.put(locale, new Vector());
return true;
}
}
/**
* Get a list of defined locales.
*
* @return Array of defined locales, in order they were created.
*/
public String[] getAvailableLocales () {
String[] data = new String[locales.size()];
locales.copyInto(data);
return data;
}
/**
* Get whether a locale is defined. The locale need not have any mappings.
*
* @param locale Locale
* @return Whether the locale is defined. False if null
*/
public boolean hasLocale (String locale) {
return (locale == null ? false : locales.contains(locale));
}
/**
* Return the next locale in order, for cycling through locales.
*
* @return Next locale following the current locale (if the current locale is the last, cycle back to the beginning).
* If the current locale is not set, return the default locale. If the default locale is not set, return null.
*/
public String getNextLocale () {
return currentLocale == null ? defaultLocale
: (String)locales.elementAt((locales.indexOf(currentLocale) + 1) % locales.size());
}
/* === MANAGING CURRENT AND DEFAULT LOCALES === */
/**
* Get the current locale.
*
* @return Current locale.
*/
public String getLocale () {
return currentLocale;
}
/**
* Set the current locale. The locale must be defined. Will notify all registered ILocalizables of the change in locale.
*
* @param currentLocale Locale. Must be defined and not null.
* @throws UnregisteredLocaleException If locale is null or not defined.
*/
public void setLocale (String currentLocale) {
if (!hasLocale(currentLocale))
throw new UnregisteredLocaleException("Attempted to set to a locale that is not defined. Attempted Locale: " + currentLocale);
if (!currentLocale.equals(this.currentLocale)) {
this.currentLocale = currentLocale;
}
loadCurrentLocaleResources();
alertLocalizables();
}
/**
* Get the default locale.
*
* @return Default locale.
*/
public String getDefaultLocale () {
return defaultLocale;
}
/**
* Set the default locale. The locale must be defined.
*
* @param defaultLocale Default locale. Must be defined. May be null, in which case there will be no default locale.
* @throws UnregisteredLocaleException If locale is not defined.
*/
public void setDefaultLocale (String defaultLocale) {
if (defaultLocale != null && !hasLocale(defaultLocale))
throw new UnregisteredLocaleException("Attempted to set default to a locale that is not defined");
this.defaultLocale = defaultLocale;
}
/**
* Set the current locale to the default locale. The default locale must be set.
*
* @throws IllegalStateException If default locale is not set.
*/
public void setToDefault () {
if (defaultLocale == null)
throw new IllegalStateException("Attempted to set to default locale when default locale not set");
setLocale(defaultLocale);
}
/**
* Constructs a body of local resources to be the set of Current Locale Data.
*
* After loading, the current locale data will contain definitions for each
* entry defined by the current locale resources, as well as definitions for any
* entry present in the fallback resources but not in those of the current locale.
*
* The procedure to accomplish this set is as follows, with overwritting occuring
* when a collision occurs:
*
* 1. Load all of the in memory definitions for the default locale if fallback is enabled
* 2. For each resource file for the default locale, load each definition if fallback is enabled
* 3. Load all of the in memory definitions for the current locale
* 4. For each resource file for the current locale, load each definition
*/
private void loadCurrentLocaleResources() {
//this.currentLocaleData = getLocaleData(currentLocale);
}
/**
* Moves all relevant entries in the source dictionary into the destination dictionary
* @param destination A dictionary of key/value locale pairs that will be modified
* @param source A dictionary of key/value locale pairs that will be copied into
* destination
*/
private void loadTable(OrderedHashtable destination, OrderedHashtable source) {
for(Enumeration en = source.keys(); en.hasMoreElements(); ) {
String key = (String)en.nextElement();
destination.put(key, (String)source.get(key));
}
}
/* === MANAGING LOCALE DATA (TEXT MAPPINGS) === */
/**
* Registers a resource file as a source of locale data for the specified
* locale.
*
* @param locale The locale of the definitions provided.
* @param resource A LocaleDataSource containing string data for the locale provided
* @throws NullPointerException if resource or locale are null
*/
public void registerLocaleResource (String locale, LocaleDataSource resource) {
if(locale == null) {
throw new NullPointerException("Attempt to register a data source to a null locale in the localizer");
}
if(resource == null) {
throw new NullPointerException("Attempt to register a null data source in the localizer");
}
Vector resources = new Vector();
if(localeResources.containsKey(locale)) {
resources = (Vector)localeResources.get(locale);
}
resources.addElement(resource);
localeResources.put(locale, resources);
if(locale.equals(currentLocale)) {
loadCurrentLocaleResources();
}
}
/**
* Get the set of mappings for a locale.
*
* @param locale Locale
* @returns Hashtable representing text mappings for this locale. Returns null if locale not defined or null.
*/
/*public OrderedHashtable getLocaleData (String locale) {
if(locale == null || !this.locales.contains(locale)) {
return null;
}
//It's very important that any default locale contain the appropriate strings to localize the interface
//for any possible language. As such, we'll keep around a table with only the default locale keys to
//ensure that there are no localizations which are only present in another locale, which causes ugly
//and difficult to trace errors.
OrderedHashtable defaultLocaleKeys = new OrderedHashtable();
//This table will be loaded with the default values first (when applicable), and then with any
//language specific translations overwriting the existing values.
OrderedHashtable data = new OrderedHashtable();
// If there's a default locale, we load all of its elements into memory first, then allow
// the current locale to overwrite any differences between the two.
if (fallbackDefaultLocale && defaultLocale != null) {
Vector defaultResources = (Vector) localeResources.get(defaultLocale);
for (int i = 0; i < defaultResources.size(); ++i) {
loadTable(data,((LocaleDataSource)defaultResources.elementAt(i)).getLocalizedText());
}
for(Enumeration en = data.keys(); en.hasMoreElements();) {
defaultLocaleKeys.put(en.nextElement(), Boolean.TRUE);
}
}
Vector resources = (Vector)localeResources.get(locale);
for(int i = 0 ; i < resources.size() ; ++i ) {
loadTable(data,((LocaleDataSource)resources.elementAt(i)).getLocalizedText());
}
//If we're using a default locale, now we want to make sure that it has all of the keys
//that the locale we want to use does. Otherwise, the app will crash when we switch to
//a locale that doesn't contain the key.
if(fallbackDefaultLocale && defaultLocale != null) {
String missingKeys = "";
int keysmissing = 0;
for(Enumeration en = data.keys(); en.hasMoreElements();) {
String key = (String)en.nextElement();
if(!defaultLocaleKeys.containsKey(key)) {
missingKeys += key + ",";
keysmissing++;
}
}
if(keysmissing > 0) {
//Is there a good way to localize these exceptions?
throw new NoLocalizedTextException("Error loading locale " + locale +
". There were " + keysmissing + " keys which were contained in this locale, but were not " +
"properly registered in the default Locale. Any keys which are added to a locale should always " +
"be added to the default locale to ensure appropriate functioning.\n" +
"The missing translations were for the keys: " + missingKeys,missingKeys, defaultLocale);
}
}
return data;
}*/
/**
* Get the mappings for a locale, but throw an exception if locale is not defined.
*
* @param locale Locale
* @return Text mappings for locale.
* @throws UnregisteredLocaleException If locale is not defined or null.
*/
/*public OrderedHashtable getLocaleMap (String locale) {
OrderedHashtable mapping = getLocaleData(locale);
if (mapping == null)
throw new UnregisteredLocaleException("Attempted to access an undefined locale.");
return mapping;
}*/
/**
* Determine whether a locale has a mapping for a given text handle. Only tests the specified locale and form; does
* not fallback to any default locale or text form.
*
* @param locale Locale. Must be defined and not null.
* @param textID Text handle.
* @return True if a mapping exists for the text handle in the given locale.
* @throws UnregisteredLocaleException If locale is not defined.
*/
public boolean hasMapping (String locale, String textID) {
if (locale == null || !locales.contains(locale)) {
throw new UnregisteredLocaleException("Attempted to access an undefined locale (" + locale + ") while checking for a mapping for " + textID);
}
Vector resources = (Vector)localeResources.get(locale);
for(Enumeration en = resources.elements(); en.hasMoreElements(); ) {
LocaleDataSource source = (LocaleDataSource)en.nextElement();
/*if(source.getLocalizedText().containsKey(textID)) {
return true;
}*/
}
return false;
}
/**
* Undefine a locale and remove all its data. Cannot be called on the current locale. If called on the default
* locale, no default locale will be set afterward.
*
* @param locale Locale to remove. Must not be null. Need not be defined. Must not be the current locale.
* @return Whether the locale existed in the first place.
* @throws IllegalArgumentException If locale is the current locale.
* @throws NullPointerException if locale is null
*/
public boolean destroyLocale (String locale) {
if (locale.equals(currentLocale))
throw new IllegalArgumentException("Attempted to destroy the current locale");
boolean removed = hasLocale(locale);
locales.removeElement(locale);
localeResources.remove(locale);
if (locale.equals(defaultLocale))
defaultLocale = null;
return removed;
}
/* === RETRIEVING LOCALIZED TEXT === */
/**
* Retrieve the localized text for a text handle in the current locale. See getText(String, String) for details.
*
* @param textID Text handle (text ID appended with optional text form). Must not be null.
* @return Localized text. If no text is found after using all fallbacks, return null.
* @throws UnregisteredLocaleException If current locale is not set.
* @throws NullPointerException if textID is null
*/
public String getText (String textID) {
return getText(textID, currentLocale);
}
/**
* Retrieve the localized text for a text handle in the current locale. See getText(String, String) for details.
*
* @param textID Text handle (text ID appended with optional text form). Must not be null.
* @param args arguments for string variables.
* @return Localized text
* @throws UnregisteredLocaleException If current locale is not set.
* @throws NullPointerException if textID is null
* @throws NoLocalizedTextException If there is no text for the specified id
*/
public String getText (String textID, String[] args) {
String text = getText(textID, currentLocale);
if(text != null) {
text = processArguments(text, args);
} else {
throw new NoLocalizedTextException("The Localizer could not find a definition for ID: " + textID + " in the '" + currentLocale + "' locale.", textID, currentLocale);
}
return text;
}
/**
* Retrieve the localized text for a text handle in the current locale. See getText(String, String) for details.
*
* @param textID Text handle (text ID appended with optional text form). Must not be null.
* @param args arguments for string variables.
* @return Localized text. If no text is found after using all fallbacks, return null.
* @throws UnregisteredLocaleException If current locale is not set.
* @throws NullPointerException if textID is null
* @throws NoLocalizedTextException If there is no text for the specified id
*/
public String getText (String textID, HashMap args) {
String text = getText(textID, currentLocale);
if(text != null) {
text = processArguments(text, args);
} else {
throw new NoLocalizedTextException("The Localizer could not find a definition for ID: " + textID + " in the '" + currentLocale + "' locale.",textID, currentLocale);
}
return text;
}
/**
* Retrieve localized text for a text handle in the current locale. Like getText(String), however throws exception
* if no localized text is found.
*
* @param textID Text handle (text ID appended with optional text form). Must not be null.
* @return Localized text
* @throws NoLocalizedTextException If there is no text for the specified id
* @throws UnregisteredLocaleException If current locale is not set
* @throws NullPointerException if textID is null
*/
public String getLocalizedText (String textID) {
String text = getText(textID);
if (text == null)
throw new NoLocalizedTextException("Can't find localized text for current locale! text id: [" + textID + "] locale: ["+currentLocale+"]", textID, currentLocale);
return text;
}
/**
* Retrieve the localized text for a text handle in the given locale. If no mapping is found initially, then,
* depending on enabled fallback modes, other places will be searched until a mapping is found.
* <p>
* The search order is thus:
* 1) Specified locale, specified text form
* 2) Specified locale, default text form
* 3) Default locale, specified text form
* 4) Default locale, default text form
* <p>
* (1) and (3) are only searched if a text form ('long', 'short', etc.) is specified.
* If a text form is specified, (2) and (4) are only searched if default-form-fallback mode is enabled.
* (3) and (4) are only searched if default-locale-fallback mode is enabled. It is not an error in this situation
* if no default locale is set; (3) and (4) will simply not be searched.
*
* @param textID Text handle (text ID appended with optional text form). Must not be null.
* @param locale Locale. Must be defined and not null.
* @return Localized text. If no text is found after using all fallbacks, return null.
* @throws UnregisteredLocaleException If the locale is not defined or null.
* @throws NullPointerException if textID is null
*/
public String getText (String textID, String locale) {
String text = getRawText(locale, textID);
if (text == null && fallbackDefaultForm && textID.indexOf(";") != -1)
text = getRawText(locale, textID.substring(0, textID.indexOf(";")));
if (text == null && fallbackDefaultLocale && !locale.equals(defaultLocale) && defaultLocale != null)
text = getText(textID, defaultLocale);
return text;
}
/**
* Get text for locale and exact text ID only, not using any fallbacks.
*
* NOTE: This call will only return the full compliment of available strings if and
* only if the requested locale is current. Otherwise it will only retrieve strings
* declared at runtime.
*
* @param locale Locale. Must be defined and not null.
* @param textID Text handle (text ID appended with optional text form). Must not be null.
* @return Localized text. Return null if none found.
* @throws UnregisteredLocaleException If the locale is not defined or null.
* @throws NullPointerException if textID is null
*/
public String getRawText (String locale, String textID) {
if(locale == null) {
throw new UnregisteredLocaleException("Null locale when attempting to fetch text id: " + textID);
}
if(locale.equals(currentLocale)) {
return (String)currentLocaleData.get(textID);
} else {
return null; //(String)getLocaleMap(locale).get(textID);
}
}
/* === MANAGING LOCALIZABLE OBSERVERS === */
/**
* Register a Localizable to receive updates when the locale is changed. If the Localizable is already
* registered, nothing happens. If a locale is currently set, the new Localizable will receive an
* immediate 'locale changed' event.
*
* @param l Localizable to register.
*/
public void registerLocalizable (Localizable l) {
if (!observers.contains(l)) {
observers.addElement(l);
if (currentLocale != null) {
l.localeChanged(currentLocale, this);
}
}
}
/**
* Unregister an Localizable from receiving locale change updates. No effect if the Localizable was never
* registered in the first place.
*
* @param l Localizable to unregister.
*/
public void unregisterLocalizable (Localizable l) {
observers.removeElement(l);
}
/**
* Unregister all ILocalizables.
*/
public void unregisterAll () {
observers.removeAllElements();
}
/**
* Send a locale change update to all registered ILocalizables.
*/
private void alertLocalizables () {
for (Enumeration e = observers.elements(); e.hasMoreElements(); )
((Localizable)e.nextElement()).localeChanged(currentLocale, this);
}
/* === Managing Arguments === */
private static String arg(String in) {
return "${" + in + "}";
}
public static Vector getArgs (String text) {
Vector args = new Vector();
int i = text.indexOf("${");
while (i != -1) {
int j = text.indexOf("}", i);
if (j == -1) {
System.err.println("Warning: unterminated ${...} arg");
break;
}
String arg = text.substring(i + 2, j);
if (!args.contains(arg)) {
args.addElement(arg);
}
i = text.indexOf("${", j + 1);
}
return args;
}
public static String processArguments(String text, HashMap args) {
int i = text.indexOf("${");
while (i != -1) {
int j = text.indexOf("}", i);
if (j == -1) {
System.err.println("Warning: unterminated ${...} arg");
break;
}
String argName = text.substring(i + 2, j);
String argVal = (String)args.get(argName);
if (argVal != null) {
text = text.substring(0, i) + argVal + text.substring(j + 1);
j = i + argVal.length() - 1;
}
i = text.indexOf("${", j + 1);
}
return text;
}
public static String processArguments(String text, String[] args) {
String working = text;
int currentArg = 0;
while(working.indexOf("${") != -1 && args.length > currentArg) {
String value = extractValue(text, args);
if(value == null) {
value = args[currentArg];
currentArg++;
}
working = replaceFirstValue(working, value);
}
return working;
}
private static String extractValue(String text, String[] args) {
//int start = text.indexOf("${");
//int end = text.indexOf("}");
//String index = text.substring(start + 2, end);
//Search for that string in the current locale, updating any arguments.
return null;
}
private static String replaceFirstValue(String text, String value) {
int start = text.indexOf("${");
int end = text.indexOf("}");
return text.substring(0,start) + value + text.substring(end + 1, text.length());
}
/* === (DE)SERIALIZATION === */
/**
* Read the object from stream.
*/
public void readExternal(DataInputStream dis, PrototypeFactory pf) throws IOException, DeserializationException {
fallbackDefaultLocale = ExtUtil.readBool(dis);
fallbackDefaultForm = ExtUtil.readBool(dis);
localeResources = (OrderedHashtable)ExtUtil.read(dis, new ExtWrapMap(String.class, new ExtWrapListPoly(), true), pf);;
locales = (Vector)ExtUtil.read(dis, new ExtWrapList(String.class));
setDefaultLocale((String)ExtUtil.read(dis, new ExtWrapNullable(String.class), pf));
String currentLocale = (String)ExtUtil.read(dis, new ExtWrapNullable(String.class), pf);
if (currentLocale != null) {
setLocale(currentLocale);
}
}
/**
* Write the object to stream.
*/
public void writeExternal(DataOutputStream dos) throws IOException {
ExtUtil.writeBool(dos, fallbackDefaultLocale);
ExtUtil.writeBool(dos, fallbackDefaultForm);
ExtUtil.write(dos, new ExtWrapMap(localeResources, new ExtWrapListPoly()));
ExtUtil.write(dos, new ExtWrapList(locales));
ExtUtil.write(dos, new ExtWrapNullable(defaultLocale));
ExtUtil.write(dos, new ExtWrapNullable(currentLocale));
}
}