SimpleLocalizationServiceImpl.java

package org.apache.fulcrum.localization;

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.
 */

import java.text.MessageFormat;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.ResourceBundle;

import org.apache.avalon.framework.activity.Initializable;
import org.apache.avalon.framework.configuration.Configurable;
import org.apache.avalon.framework.configuration.Configuration;
import org.apache.avalon.framework.configuration.ConfigurationException;
import org.apache.avalon.framework.logger.AbstractLogEnabled;
import org.apache.commons.lang3.StringUtils;

/**
 * <p>
 * This class is the single point of access to all localization
 * resources.  It caches different ResourceBundles for different
 * Locales.
 * </p>
 *
 * <p>Usage example:</p>
 *
 * <pre>
 * <code>
 * SimpleLocalizationService ls = (SimpleLocalizationService) TurbineServices
 *     .getInstance().getService(SimpleLocalizationService.SERVICE_NAME);
 * </code>
 * </pre>
 *
 * <p>
 * Then call {@link #getString(String, Locale, String)}, or one of
 * two methods to retrieve a ResourceBundle:
 * </p>
 *
 * <ul>
 * <li>getBundle("MyBundleName")</li>
 * <li>getBundle("MyBundleName", Locale)</li>
 * <li>etc.</li>
 * </ul>
 *
 * @author <a href="mailto:jm@mediaphil.de">Jonas Maurus</a>
 * @author <a href="mailto:jon@latchkey.com">Jon S. Stevens</a>
 * @author <a href="mailto:novalidemail@foo.com">Frank Y. Kim</a>
 * @author <a href="mailto:dlr@finemaltcoding.com">Daniel Rall</a>
 * @author <a href="mailto:leonardr@collab.net">Leonard Richardson</a>
 * @author <a href="mailto:mcconnell@apache.org">Stephen McConnell</a>
 * @author <a href="mailto:tv@apache.org">Thomas Vandahl</a>
 * @version $Id: DefaultLocalizationService.java 535465 2007-05-05 06:58:06Z tv $
 * 
 * avalon.component name="localization" lifestyle="singleton"
 * avalon.service type="org.apache.fulcrum.localization.SimpleLocalizationService"
 */
public class SimpleLocalizationServiceImpl
    extends AbstractLogEnabled
    implements SimpleLocalizationService, Configurable, Initializable
{
    /** Key Prefix for our bundles */
    private static final String BUNDLES = "bundles";
    
    /**
     * The value to pass to <code>MessageFormat</code> if a
     * <code>null</code> reference is passed to <code>format()</code>.
     */
    private static final Object[] NO_ARGS = new Object[0];
    
    /**
     * Bundle name keys a HashMap of the ResourceBundles in this
     * service (which is in turn keyed by Locale).
     */
    private HashMap<String, HashMap<Locale, ResourceBundle>> bundles = null;
    
    /**
     * The list of default bundles to search.
     */
    private String[] bundleNames = null;

    /**
     * The name of the default locale to use (includes language and
     * country).
     */
    private Locale defaultLocale = null;
    
    /** The name of the default language to use. */
    private String defaultLanguage = null;
    
    /** The name of the default country to use. */
    private String defaultCountry = null;

    /**
     * Creates a new instance.
     */
    public SimpleLocalizationServiceImpl()
    {
        bundles = new HashMap<String, HashMap<Locale, ResourceBundle>>();
    }

    /**
     * Avalon lifecycle method
     * 
     * {@link org.apache.avalon.framework.configuration.Configurable}
     * 
     * @param conf the configuration
     * @throws ConfigurationException if failed to configure
     */
    @Override
	public void configure(Configuration conf) throws ConfigurationException
    {
        Locale jvmDefault = Locale.getDefault();
        defaultLanguage =
            conf
                .getAttribute(
                    "locale-default-language",
                    jvmDefault.getLanguage())
                .trim();
        defaultCountry =
            conf
                .getAttribute("locale-default-country", jvmDefault.getCountry())
                .trim();
        // FIXME! need to add bundle names
        getLogger().info(
            "initialized lang="
                + defaultLanguage
                + " country="
                + defaultCountry);
        final Configuration bundles = conf.getChild(BUNDLES, false);
        if (bundles != null)
        {
            Configuration[] nameVal = bundles.getChildren();
            String bundleName[] = new String[nameVal.length];
            for (int i = 0; i < nameVal.length; i++)
            {
                String val = nameVal[i].getValue();
                getLogger().debug("Registered bundle " + val);
                bundleName[i] = val;
            }
            initBundleNames(bundleName);
        }
    }
    
    /**
     * Called the first time the Service is used.
     * 
     * @throws Exception generic exception
     */
    @Override
	public void initialize() throws Exception
    {
        defaultLocale = new Locale(defaultLanguage, defaultCountry);
        if (getLogger().isInfoEnabled())
        {
            getLogger().info("Localization Service is Initialized now..");
        }
    }
    
    /**
     * Initialize list of default bundle names.
     *
     * @param intBundleNames set bundle names
     */
    protected void initBundleNames(String[] intBundleNames)
    {
        if (intBundleNames == null)
            bundleNames = new String[0];
        else
        	bundleNames = intBundleNames;
    }
    
    /**
     * Retrieves the default language (specified in the config file).
     * 
     * @return the default language
     */
    @Override
	public String getDefaultLanguage()
    {
        return defaultLanguage;
    }
    
    /**
     * Retrieves the default country (specified in the config file).
     * 
     * @return the default country
     */
    @Override
	public String getDefaultCountry()
    {
        return defaultCountry;
    }
    
    /**
     * Retrieves the default Locale (as created from default
     * language and default country).
     * 
     * @return the default locale
     */
    @Override
	public Locale getDefaultLocale()
    {
        return defaultLocale;
    }

    /**
     * {@link org.apache.fulcrum.localization.SimpleLocalizationService#getDefaultBundleName()}
     * 
     * @return the default bundle name
     */
    @Override
	public String getDefaultBundleName()
    {
        return (bundleNames.length > 0 ? bundleNames[0] : "");
    }
    
    /**
     * {@link org.apache.fulcrum.localization.SimpleLocalizationService#getBundleNames()}
     * 
     * @return list of bundle names available
     */
    @Override
	public String[] getBundleNames()
    {
        return bundleNames.clone();
    }
    
    /**
     * @see org.apache.fulcrum.localization.SimpleLocalizationService#getBundle()
     * 
     * @return the default resource bundle
     */
    @Override
	public ResourceBundle getBundle()
    {
        return getBundle(getDefaultBundleName(), (Locale) null);
    }
    
    /**
     * @see org.apache.fulcrum.localization.SimpleLocalizationService#getBundle(java.lang.String)
     * 
     * @param bundleName the name of a bundle
     * @return the resource bundle
     */
    @Override
	public ResourceBundle getBundle(String bundleName)
    {
        return getBundle(bundleName, (Locale) null);
    }
    
    /**
     * @see org.apache.fulcrum.localization.SimpleLocalizationService#getBundle(java.lang.String, java.util.Locale)
     * 
     * This method returns a ResourceBundle for the given bundle name
     * and the given Locale.
     *
     * @param bundleName Name of bundle (or <code>null</code> for the
     * default bundle).
     * @param locale The locale (or <code>null</code> for the locale
     * indicated by the default language and country).
     * @return A localized ResourceBundle.
     */
    @Override
	public ResourceBundle getBundle(String bundleName, Locale locale)
    {
        // Assure usable inputs.
        bundleName =
            (bundleName == null ? getDefaultBundleName() : bundleName.trim());
        if (locale == null)
        {
            locale = getDefaultLocale();
        }
        // Find/retrieve/cache bundle.
        ResourceBundle rb = null;
        HashMap<?, ?> bundlesByLocale = (HashMap<?, ?>) bundles.get(bundleName);
        if (bundlesByLocale != null)
        {
            // Cache of bundles by locale for the named bundle exists.
            // Check the cache for a bundle corresponding to locale.
            rb = (ResourceBundle) bundlesByLocale.get(locale);
            if (rb == null)
            {
                // Not yet cached.
                rb = cacheBundle(bundleName, locale);
            }
        }
        else
        {
            rb = cacheBundle(bundleName, locale);
        }
        return rb;
    }
    
    /**
     * Caches the named bundle for fast lookups.  This operation is
     * relatively expensive in terms of memory use, but is optimized
     * for run-time speed in the usual case.
     *
     * @param bundleName Name of bundle (or <code>null</code> for the
     * default bundle).
     * @param locale The locale (or <code>null</code> for the locale
     * indicated by the default language and country).
     * @throws MissingResourceException Bundle not found.
     * @return a localized resource bundle
     */
    private synchronized ResourceBundle cacheBundle(
        String bundleName,
        Locale locale)
        throws MissingResourceException
    {
        HashMap<Locale, ResourceBundle> bundlesByLocale = (HashMap<Locale, ResourceBundle>) bundles.get(bundleName);
        ResourceBundle rb =
            (bundlesByLocale == null
                ? null
                : (ResourceBundle) bundlesByLocale.get(locale));
        if (rb == null)
        {
            bundlesByLocale =
                (bundlesByLocale == null
                    ? new HashMap<Locale, ResourceBundle>(3)
                    : new HashMap<Locale, ResourceBundle>(bundlesByLocale));
            try
            {
                rb = ResourceBundle.getBundle(bundleName, locale);
            }
            catch (MissingResourceException e)
            {
                rb = findBundleByLocale(bundleName, locale, bundlesByLocale);
                if (rb == null)
                {
                    throw (MissingResourceException) e.fillInStackTrace();
                }
            }
            if (rb != null)
            {
                // Cache bundle.
                bundlesByLocale.put(rb.getLocale(), rb);
                HashMap<String, HashMap<Locale, ResourceBundle>> bundlesByName 
                	= new HashMap<String, HashMap<Locale, ResourceBundle>>(bundles);
                bundlesByName.put(bundleName, bundlesByLocale);
                this.bundles = bundlesByName;
            }
        }
        return rb;
    }
    
    /**
     * <p>Retrieves the bundle most closely matching first against the
     * supplied inputs, then against the defaults.</p>
     *
     * <p>Use case: some clients send a HTTP Accept-Language header
     * with a value of only the language to use
     * (i.e. "Accept-Language: en"), and neglect to include a country.
     * When there is no bundle for the requested language, this method
     * can be called to try the default country (checking internally
     * to assure the requested criteria matches the default to avoid
     * disconnects between language and country).</p>
     *
     * <p>Since we're really just guessing at possible bundles to use,
     * we don't ever throw <code>MissingResourceException</code>.</p>
     * 
     * @param bundleName Name of bundle (or <code>null</code> for the
     * default bundle).
     * @param locale The locale (or <code>null</code> for the locale
     * indicated by the default language and country).
     * @param bundleByLocale map of locales and resource bundles
     * @return a localized resource bundle
     * 
     */
    private ResourceBundle findBundleByLocale(
        String bundleName,
        Locale locale,
        Map<Locale, ResourceBundle> bundlesByLocale)
    {
        ResourceBundle rb = null;
        if (StringUtils.isEmpty(locale.getCountry())
            && defaultLanguage.equals(locale.getLanguage()))
        {
            /*
             *            category.debug("Requested language '" + locale.getLanguage() +
             *                           "' matches default: Attempting to guess bundle " +
             *                           "using default country '" + defaultCountry + '\'');
             */
            Locale withDefaultCountry =
                new Locale(locale.getLanguage(), defaultCountry);
            rb = (ResourceBundle) bundlesByLocale.get(withDefaultCountry);
            if (rb == null)
            {
                rb = getBundleIgnoreException(bundleName, withDefaultCountry);
            }
        }
        else if (
            StringUtils.isEmpty(locale.getLanguage())
                && defaultCountry.equals(locale.getCountry()))
        {
            Locale withDefaultLanguage =
                new Locale(defaultLanguage, locale.getCountry());
            rb = (ResourceBundle) bundlesByLocale.get(withDefaultLanguage);
            if (rb == null)
            {
                rb = getBundleIgnoreException(bundleName, withDefaultLanguage);
            }
        }
        
        if (rb == null && !defaultLocale.equals(locale))
        {
            rb = getBundleIgnoreException(bundleName, defaultLocale);
        }
        return rb;
    }
    
    /**
     * Retrieves the bundle using the
     * <code>ResourceBundle.getBundle(String, Locale)</code> method,
     * returning <code>null</code> instead of throwing
     * <code>MissingResourceException</code>.
     * 
     * @param bundleName Name of bundle (or <code>null</code> for the
     * default bundle).
     * @param locale The locale (or <code>null</code> for the locale
     * indicated by the default language and country).
     */
    private final ResourceBundle getBundleIgnoreException(
        String bundleName,
        Locale locale)
    {
        try
        {
            return ResourceBundle.getBundle(bundleName, locale);
        }
        catch (MissingResourceException ignored)
        {
            return null;
        }
    }
    
    /**
     * This method sets the name of the first bundle in the search
     * list (the "default" bundle).
     *
     * @param defaultBundle Name of default bundle.
     */
    @Override
	public void setBundle(String defaultBundle)
    {
        if (bundleNames.length > 0)
        {
            bundleNames[0] = defaultBundle;
        }
        else
        {
            synchronized (this)
            {
                if (bundleNames.length <= 0)
                {
                    bundleNames = new String[] { defaultBundle };
                }
            }
        }
    }
    
    /**
     * 
     * {@link org.apache.fulcrum.localization.SimpleLocalizationService#getString(String, Locale, String)}
     * @throws MissingResourceException Specified key cannot be matched.
     */
    @Override
	public String getString(String bundleName, Locale locale, String key)
      throws MissingResourceException
    {
        String value = null;
        if (locale == null)
        {
            locale = getDefaultLocale();
        }
        // Look for text in requested bundle.
        ResourceBundle rb = getBundle(bundleName, locale);
        value = getStringOrNull(rb, key);
        // Look for text in list of default bundles.
        if (value == null && bundleNames.length > 0)
        {
            String name;
            for (int i = 0; i < bundleNames.length; i++)
            {
                name = bundleNames[i];
                //System.out.println("getString(): name=" + name +
                //                   ", locale=" + locale + ", i=" + i);
                if (!name.equals(bundleName))
                {
                    rb = getBundle(name, locale);
                    value = getStringOrNull(rb, key);
                    if (value != null)
                    {
                        locale = rb.getLocale();
                        break;
                    }
                }
            }
        }
        if (value == null)
        {
            String loc = locale.toString();
            String mesg =
                LocalizationService.SERVICE_NAME
                    + " noticed missing resource: "
                    + "bundleName="
                    + bundleName
                    + ", locale="
                    + loc
                    + ", key="
                    + key;
            getLogger().debug(mesg);
            // Text not found in requested or default bundles.
            throw new MissingResourceException(mesg, bundleName, key);
        }
        return value;
    }
    
    /**
     * Returns the value for the key in the default bundle and the default locale.
     * 
     * @param key The key to retrieve the value for.
     * @return The value mapped to the key.
     */
    @Override
	public String getString(String key)
    {
        return getString(getDefaultBundleName(), getDefaultLocale(), key);
    }
    
    
    /**
     * Gets localized text from a bundle if it's there.  Otherwise,
     * returns <code>null</code> (ignoring a possible
     * <code>MissingResourceException</code>).
     * 
     * @param rb resource bundle 
     * @param key The key to retrieve the value for.
     * @return name of resource
     */
    protected final String getStringOrNull(ResourceBundle rb, String key)
    {
        if (rb != null)
        {
            try
            {
                return rb.getString(key);
            }
            catch (MissingResourceException ignored)
            {
                // ignore
            }
        }
        return null;
    }
    
    /**
     * {@link org.apache.fulcrum.localization.SimpleLocalizationService#format(String, Locale, String, Object)}
     * @param bundleName the bundle name
     * @param locale locale
     * @param key key to lookup
     * @param arg1 bundle arguments
     */
    @Override
	public String format(
        String bundleName,
        Locale locale,
        String key,
        Object arg1)
    {
        return format(bundleName, locale, key, new Object[] { arg1 });
    }
    
    /* (non-Javadoc)
     * @see org.apache.fulcrum.localization.SimpleLocalizationService#format(java.lang.String, java.util.Locale, java.lang.String, java.lang.Object, java.lang.Object)
     */
    @Override
	public String format(
        String bundleName,
        Locale locale,
        String key,
        Object arg1,
        Object arg2)
    {
        return format(bundleName, locale, key, new Object[] { arg1, arg2 });
    }
    
    /**
     * Looks up the value for <code>key</code> in the
     * <code>ResourceBundle</code> referenced by
     * <code>bundleName</code>, then formats that value for the
     * specified <code>Locale</code> using <code>args</code>.
     * 
     * If <code>locale</code> is <code>null</code>, {@link #getDefaultLocale()} will be checked.
     * If <code>bundleName</code> is <code>null</code>, {@link #getDefaultBundleName()} will be checked (cft. {@link #getBundle(String, Locale)}.
     *
     * @return Localized, formatted text identified by
     * <code>key</code>.
     */
    @Override
	public String format(
        String bundleName,
        Locale locale,
        String key,
        Object[] args)
    {
        // When formatting Date objects and such, MessageFormat
        // cannot have a null Locale.
        Locale formatLocale = (locale == null) ? getDefaultLocale() : locale; 
        String value = getString(bundleName, locale, key);
        
        Object[] formatArgs = (args == null) ? NO_ARGS : args;
        
        MessageFormat messageFormat = new MessageFormat(value, formatLocale);
        return messageFormat.format(formatArgs);
    }
}