/*!
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License, version 2.1 as published by the Free Software
* Foundation.
*
* You should have received a copy of the GNU Lesser General Public License along with this
* program; if not, you can obtain a copy at http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
* or from the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*
* This program 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 Lesser General Public License for more details.
*
* Copyright (c) 2002-2013 Pentaho Corporation.. All rights reserved.
*/
package org.pentaho.gwt.widgets.client.utils.i18n;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.http.client.Header;
import com.google.gwt.http.client.Request;
import com.google.gwt.http.client.RequestBuilder;
import com.google.gwt.http.client.RequestCallback;
import com.google.gwt.http.client.RequestException;
import com.google.gwt.http.client.Response;
import com.google.gwt.user.client.Command;
import com.google.gwt.user.client.Window;
import com.google.gwt.user.client.ui.HTML;
import org.pentaho.gwt.widgets.client.utils.string.StringTokenizer;
import org.pentaho.gwt.widgets.client.utils.string.StringUtils;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
/**
* This class is a ResourceBundle for GWT projects. Provided with a resource's base-name it will fetch and merge
* resources as follows:
*
* 1. base-name.properties 2. base-name_xx.properties (where XX = language, such as en) 3. base-name_xx_yy.properties
* (where yy = country, such as US)
*
* When each new resource is fetched it is merged with previous resources. Resource collisions are resolved by
* overwriting existing resources with new resources. In this way we are able to provide language/country overrides
* above the default bundles.
*
* @author Michael D'Amour
*/
public class ResourceBundle {
private static final Map<String, String> bundleCache = new HashMap<String, String>();
public static final String PROPERTIES_EXTENSION = ".properties"; //$NON-NLS-1$
private HashMap<String, String> bundle = new HashMap<String, String>();
private RequestCallback baseCallback = null;
private RequestCallback langCallback = null;
private RequestCallback langCountryCallback = null;
private String path = null;
private String bundleName = null;
private IResourceBundleLoadCallback bundleLoadCallback = null;
private String localeName = "default"; //$NON-NLS-1$
private String currentAttemptUrl = null;
private boolean attemptLocalizedFetches = true;
private Map<String, String> supportedLanguages = null;
private class FakeResponse extends Response {
private String text;
public FakeResponse( String text ) {
this.text = text;
}
public String getHeader( String arg0 ) {
return null;
}
public Header[] getHeaders() {
return null;
}
public String getHeadersAsString() {
return null;
}
public int getStatusCode() {
return Response.SC_OK;
}
public String getStatusText() {
return null;
}
public String getText() {
return text;
}
}
public ResourceBundle() {
this.localeName = StringUtils.defaultIfEmpty( Window.Location.getParameter( "locale" ), getLanguagePreference() ); //$NON-NLS-1$
}
/**
* The MessageBundle class fetches localized properties files by using the GWT RequestBuilder against the supplied
* path. Ideally the path should be relative, but absolute paths are accepted. When the ResourceBundle has fetched and
* loaded all available resources it will notify the caller by way of IMessageBundleLoadCallback. This is necessary
* due to the asynchronous nature of the loading process. Care should be taken to be sure not to request resources
* until loading has finished as inconsistent and incomplete results will be likely.
*
* @param path
* The path to the resources (mantle/messages)
* @param bundleName
* The base name of the set of resource bundles, for example 'messages'
* @param bundleLoadCallback
* The callback to invoke when the bundle has finished loading
*/
public ResourceBundle( String path, String bundleName, boolean attemptLocalizedFetches,
IResourceBundleLoadCallback bundleLoadCallback ) {
this();
loadBundle( path, bundleName, attemptLocalizedFetches, bundleLoadCallback );
}
public void loadBundle( String path, final String bundleName, boolean attemptLocalizedFetches,
IResourceBundleLoadCallback bundleLoadCallback ) {
this.bundleName = bundleName;
this.bundleLoadCallback = bundleLoadCallback;
this.attemptLocalizedFetches = attemptLocalizedFetches;
if ( !StringUtils.isEmpty( path ) && !path.endsWith( "/" ) ) { //$NON-NLS-1$
path = path + "/"; //$NON-NLS-1$
}
this.path = path;
// get the locale meta property if the url parameter is missing
initCallbacks();
// decompose locale
// _en_US
// 1. bundleName.properties
// 2. bundleName_en.properties
// 3. bundleName_en_US.properties
final ResourceBundle supportedLanguagesBundle = new ResourceBundle();
// callback for when supported_locales has been fetched (if desired)
IResourceBundleLoadCallback supportedLangCallback = new IResourceBundleLoadCallback() {
public void bundleLoaded( String ignore ) {
// supportedLanguages will be null if the user did not set them prior to loadBundle
// if the user already set them, keep 'em, it's an override
if ( ResourceBundle.this.supportedLanguages == null ) {
ResourceBundle.this.supportedLanguages = supportedLanguagesBundle.getMap();
decodeMapValues( ResourceBundle.this.supportedLanguages );
}
// always fetch the base first
currentAttemptUrl = ResourceBundle.this.path + bundleName + PROPERTIES_EXTENSION + getUrlExtras();
if ( bundleCache.containsKey( currentAttemptUrl ) ) {
// call in a separate timeout, to simulate the request builder call as closely as possible
Scheduler.get().scheduleDeferred( new Command() {
public void execute() {
baseCallback.onResponseReceived( null, new FakeResponse( bundleCache.get( currentAttemptUrl ) ) );
}
} );
} else {
RequestBuilder requestBuilder = new RequestBuilder( RequestBuilder.GET, currentAttemptUrl );
try {
requestBuilder.sendRequest( null, baseCallback );
} catch ( RequestException e ) {
Window.alert( "base load: " + e.getMessage() ); //$NON-NLS-1$
fireBundleLoadCallback();
}
}
}
};
// supportedLanguages will not be null if they've already been set by the user, and in that case,
// we do not want attempt to load that bundle..
if ( attemptLocalizedFetches && supportedLanguages == null ) {
// load supported_languages bundle
supportedLanguagesBundle.loadBundle( path, bundleName + "_supported_languages", false, supportedLangCallback ); //$NON-NLS-1$
} else {
// simulate callback
supportedLangCallback.bundleLoaded( bundleName + "_supported_languages" ); //$NON-NLS-1$
}
}
private void initCallbacks() {
baseCallback = new RequestCallback() {
public void onError( Request request, Throwable exception ) {
Window.alert( "baseCallback: " + exception.getMessage() ); //$NON-NLS-1$
fireBundleLoadCallback();
}
public void onResponseReceived( Request request, Response response ) {
String propertiesFileText = response.getText();
// build a simple map of key/value pairs from the properties file
if ( response.getStatusCode() == Response.SC_OK ) {
bundle = PropertiesUtil.buildProperties( propertiesFileText, bundle );
if ( response instanceof FakeResponse == false ) {
// this is a real bundle load
bundleCache.put( currentAttemptUrl, propertiesFileText );
}
} else {
// put empty bundle in cache (not found, but we want to remember it was not found)
bundleCache.put( currentAttemptUrl, "" ); //$NON-NLS-1$
}
// if we are not attempting to fetch any localized bundles
// then fire our callback and then return, we're done
if ( !attemptLocalizedFetches ) {
fireBundleLoadCallback();
return;
}
// now fetch the the lang/country variants
if ( localeName.equalsIgnoreCase( "default" ) ) { //$NON-NLS-1$
// process only bundleName.properties
fireBundleLoadCallback();
return;
} else {
StringTokenizer st = new StringTokenizer( localeName, '_' );
if ( st.countTokens() > 0 ) {
String lang = st.tokenAt( 0 );
// 2. fetch bundleName_lang.properties
// 3. fetch bundleName_lang_country.properties
currentAttemptUrl = path + bundleName + "_" + lang + PROPERTIES_EXTENSION + getUrlExtras(); //$NON-NLS-1$
// IE caches the file and causes an issue with the request
if ( !isSupportedLanguage( lang ) || bundleCache.containsKey( currentAttemptUrl ) ) {
langCallback.onResponseReceived( null, new FakeResponse( bundleCache.get( currentAttemptUrl ) ) );
} else {
RequestBuilder requestBuilder = new RequestBuilder( RequestBuilder.GET, currentAttemptUrl );
// Caching causing some strange behavior with IE6.
// TODO: Investigate caching issue.
requestBuilder.setHeader( "Cache-Control", "no-cache" ); //$NON-NLS-1$ //$NON-NLS-2$
try {
requestBuilder.sendRequest( null, langCallback );
} catch ( RequestException e ) {
Window.alert( "lang: " + e.getMessage() ); //$NON-NLS-1$
fireBundleLoadCallback();
}
}
} else if ( st.countTokens() == 0 ) {
// already fetched
fireBundleLoadCallback();
return;
}
}
}
};
langCallback = new RequestCallback() {
public void onError( Request request, Throwable exception ) {
Window.alert( "langCallback: " + exception.getMessage() ); //$NON-NLS-1$
fireBundleLoadCallback();
}
public void onResponseReceived( Request request, Response response ) {
String propertiesFileText = response.getText();
// build a simple map of key/value pairs from the properties file
if ( response.getStatusCode() == Response.SC_OK ) {
bundle = PropertiesUtil.buildProperties( propertiesFileText, bundle );
if ( response instanceof FakeResponse == false ) {
// this is a real bundle load
bundleCache.put( currentAttemptUrl, propertiesFileText );
}
} else {
// put empty bundle in cache (not found, but we want to remember it was not found)
bundleCache.put( currentAttemptUrl, "" ); //$NON-NLS-1$
}
StringTokenizer st = new StringTokenizer( localeName, '_' );
if ( st.countTokens() == 2 ) {
// 3. fetch bundleName_lang_country.properties
// need to match case-insensitive on country
if( !isSupportedLanguage( localeName ) ) {
// try to switch the case on the trailing characters
if( isSupportedLanguage( st.tokenAt( 0 ) + "_" + st.tokenAt( 1 ).toUpperCase() ) ) {
localeName = st.tokenAt( 0 ) + "_" + st.tokenAt( 1 ).toUpperCase();
}
}
currentAttemptUrl = path + bundleName + "_" + localeName + PROPERTIES_EXTENSION + getUrlExtras(); //$NON-NLS-1$
if ( !isSupportedLanguage( localeName ) || bundleCache.containsKey( currentAttemptUrl ) ) {
langCountryCallback.onResponseReceived( null, new FakeResponse( bundleCache.get( currentAttemptUrl ) ) );
} else {
RequestBuilder requestBuilder = new RequestBuilder( RequestBuilder.GET, currentAttemptUrl );
try {
requestBuilder.sendRequest( null, langCountryCallback );
} catch ( RequestException e ) {
Window.alert( "langCountry: " + e.getMessage() ); //$NON-NLS-1$
fireBundleLoadCallback();
}
}
} else {
// already fetched
fireBundleLoadCallback();
return;
}
}
};
langCountryCallback = new RequestCallback() {
public void onError( Request request, Throwable exception ) {
Window.alert( "langCountryCallback: " + exception.getMessage() ); //$NON-NLS-1$
fireBundleLoadCallback();
}
public void onResponseReceived( Request request, Response response ) {
String propertiesFileText = response.getText();
// build a simple map of key/value pairs from the properties file
if ( response.getStatusCode() == Response.SC_OK ) {
bundle = PropertiesUtil.buildProperties( propertiesFileText, bundle );
if ( response instanceof FakeResponse == false ) {
// this is a real bundle load
bundleCache.put( currentAttemptUrl, propertiesFileText );
}
} else {
// put empty bundle in cache (not found, but we want to remember it was not found)
bundleCache.put( currentAttemptUrl, "" ); //$NON-NLS-1$
}
fireBundleLoadCallback();
}
};
}
private void fireBundleLoadCallback() {
if ( bundleLoadCallback != null ) {
bundleLoadCallback.bundleLoaded( bundleName );
}
}
public String getString( String key ) {
String resource = bundle.get( key );
if ( resource == null ) {
return key;
}
return decodeUTF8( resource );
}
/**
* This method returns the value for the given key with UTF-8 respected if supplied in \\uXXXX style format (single
* forward slash, u, followed by 4 digits). UTF-8 escaped values are replaced with entity escaping, such as 'ā'
* for proper consumption by web browsers.
*
* @param key
* The name of the resource being requested
* @return The UTF-8 friendly value found for the given key
*/
public String getString( String key, String defaultValue ) {
String resource = bundle.get( key );
if ( resource == null ) {
return defaultValue;
}
return decodeUTF8( resource );
}
/**
* This method return the value for the given key with UTF-8 respected and will replace {n} tokens with the parameters
* that are passed in.
*
* @param key
* The name of the resource being requested
* @param parameters
* The values to replace occurrences of {n} in the found resource
* @return The UTF-8 friendly value found for the given key
*/
public String getString( String key, String defaultValue, String... parameters ) {
String resource = bundle.get( key );
if ( resource == null ) {
return defaultValue;
}
for ( int i = 0; i < parameters.length; i++ ) {
resource = resource.replace( "{" + i + "}", parameters[i] ); //$NON-NLS-1$ //$NON-NLS-2$
}
return decodeUTF8( resource );
}
/**
* This method returns the set of keys for the MessageBundle
*
* @return The key set for the message bundle
*/
public Set<String> getKeys() {
return bundle.keySet();
}
/**
* This method returns the internal Map of key/value pairs for the bundle
*
* @return The key set for the message bundle
*/
public Map<String, String> getMap() {
return bundle;
}
public static void clearCache() {
bundleCache.clear();
}
public void mergeResourceBundle( ResourceBundle inBundle ) {
// the incoming bundle will override the defaults in bundle
bundle.putAll( inBundle.bundle );
}
private static HTML entityDecoder = new HTML();
private String decodeUTF8( String str ) {
if ( str == null ) {
return str;
}
while ( str.indexOf( "\\u" ) != -1 ) { //$NON-NLS-1$
int index = str.indexOf( "\\u" ); //$NON-NLS-1$
String hex = str.substring( index + 2, index + 6 );
str = str.substring( 0, index ) + "" + hex + ";" + str.substring( index + 6 ); //$NON-NLS-1$ //$NON-NLS-2$
entityDecoder.setHTML( str );
str = entityDecoder.getHTML();
}
return str;
}
public boolean isSupportedLanguage( String languageCode ) {
if ( supportedLanguages == null ) {
// if supportedLocales is null or empty, then we have no idea what we support
// so we'll force try anything
return true;
}
boolean returnValue = supportedLanguages.containsKey( languageCode );
return returnValue;
}
public Map<String, String> getSupportedLanguages() {
return supportedLanguages;
}
public void setSupportedLanguages( Map<String, String> supportedLanguages ) {
this.supportedLanguages = supportedLanguages;
}
// Run the values of a map through the decodeUTF8 function to un-escape unicode characters
private void decodeMapValues( Map<String, String> map ) {
for ( Map.Entry<String, String> entry : map.entrySet() ) {
entry.setValue( decodeUTF8( entry.getValue() ) );
}
}
private native String getUrlExtras()
/*-{
return (document.all) ? "?rand="+(Math.random()*10000) : "";
}-*/;
private static native String getLanguagePreference()
/*-{
var m = $doc.getElementsByTagName('meta');
for(var i in m) {
if(m[i].name == 'gwt:property' && m[i].content.indexOf('locale=') != -1) {
return m[i].content.substring(m[i].content.indexOf('=')+1);
}
}
return "default";
}-*/;
}