Java tutorial
/* $This file is distributed under the terms of the license in /doc/license.txt$ */ package edu.cornell.mannlib.vitro.webapp.i18n; import java.io.IOException; import java.util.Comparator; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.MissingResourceException; import java.util.ResourceBundle; import java.util.SortedSet; import java.util.TreeSet; import java.util.concurrent.atomic.AtomicReference; import javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import edu.cornell.mannlib.vitro.webapp.controller.VitroRequest; import edu.cornell.mannlib.vitro.webapp.i18n.selection.SelectedLocale; import edu.cornell.mannlib.vitro.webapp.utils.developer.DeveloperSettings; import edu.cornell.mannlib.vitro.webapp.utils.developer.Key; /** * Provides access to a bundle of text strings, based on the name of the bundle, * the Locale of the requesting browser, and the current theme directory. * * If the bundle name is not specified, the default name of "all" is used. * * If a requested bundle is not found, no error is thrown. Instead, an empty * bundle is returned that produces error message strings when asked for text. */ public class I18n { private static final Log log = LogFactory.getLog(I18n.class); public static final String DEFAULT_BUNDLE_NAME = "all"; /** * If this attribute is present on the request, then the cache has already * been cleared. */ private static final String ATTRIBUTE_CACHE_CLEARED = I18n.class.getName() + "-cacheCleared"; /** * This is where the work gets done. Not declared final, so it can be * modified in unit tests. */ private static I18n instance = new I18n(); // ---------------------------------------------------------------------- // Static methods // ---------------------------------------------------------------------- /** * A convenience method to get a bundle and format the text. */ public static String text(String bundleName, HttpServletRequest req, String key, Object... parameters) { return bundle(bundleName, req).text(key, parameters); } /** * A convenience method to get the default bundle and format the text. */ public static String text(HttpServletRequest req, String key, Object... parameters) { return bundle(req).text(key, parameters); } /** * Get a I18nBundle by this name. */ public static I18nBundle bundle(String bundleName, HttpServletRequest req) { return instance.getBundle(bundleName, req); } /** * Get the default I18nBundle. */ public static I18nBundle bundle(HttpServletRequest req) { return instance.getBundle(DEFAULT_BUNDLE_NAME, req); } // ---------------------------------------------------------------------- // The instance // ---------------------------------------------------------------------- /** Holds the current theme directory, as far as we know. */ private AtomicReference<String> themeDirectory = new AtomicReference<String>(""); /** * Get an I18nBundle by this name. The request provides the preferred * Locale, the application directory, the theme directory and the * development mode flag. * * If the request indicates that the system is in development mode, then the * cache is cleared on each request. * * If the theme directory has changed, the cache is cleared. * * Declared 'protected' so it can be overridden in unit tests. */ protected I18nBundle getBundle(String bundleName, HttpServletRequest req) { log.debug("Getting bundle '" + bundleName + "'"); I18nLogger i18nLogger = new I18nLogger(); try { checkDevelopmentMode(req); checkForChangeInThemeDirectory(req); String dir = themeDirectory.get(); ServletContext ctx = req.getSession().getServletContext(); ResourceBundle.Control control = new ThemeBasedControl(ctx, dir); ResourceBundle rb = ResourceBundle.getBundle(bundleName, req.getLocale(), control); return new I18nBundle(bundleName, rb, i18nLogger); } catch (MissingResourceException e) { log.warn("Didn't find text bundle '" + bundleName + "'"); return I18nBundle.emptyBundle(bundleName, i18nLogger); } catch (Exception e) { log.error("Failed to create text bundle '" + bundleName + "'", e); return I18nBundle.emptyBundle(bundleName, i18nLogger); } } /** * If we are in development mode, clear the cache on each request. */ private void checkDevelopmentMode(HttpServletRequest req) { if (DeveloperSettings.getInstance().getBoolean(Key.I18N_DEFEAT_CACHE)) { log.debug("In development mode - clearing the cache."); clearCacheOnRequest(req); } } /** * If the theme directory has changed from before, clear the cache of all * ResourceBundles. */ private void checkForChangeInThemeDirectory(HttpServletRequest req) { String currentDir = new VitroRequest(req).getAppBean().getThemeDir(); String previousDir = themeDirectory.getAndSet(currentDir); if (!currentDir.equals(previousDir)) { log.debug("Theme directory changed from '" + previousDir + "' to '" + currentDir + "' - clearing the cache."); clearCacheOnRequest(req); } } /** Only clear the cache one time per request. */ private void clearCacheOnRequest(HttpServletRequest req) { if (req.getAttribute(ATTRIBUTE_CACHE_CLEARED) != null) { log.debug("Cache was already cleared on this request."); } else { ResourceBundle.clearCache(); log.debug("Cache cleared."); req.setAttribute(ATTRIBUTE_CACHE_CLEARED, Boolean.TRUE); } } // ---------------------------------------------------------------------- // Control classes for instantiating ResourceBundles // ---------------------------------------------------------------------- /** * Instead of looking in the classpath, look in the theme i18n directory and * the application i18n directory. */ static class ThemeBasedControl extends ResourceBundle.Control { private static final String BUNDLE_DIRECTORY = "i18n/"; private final ServletContext ctx; private final String themeDirectory; public ThemeBasedControl(ServletContext ctx, String themeDirectory) { this.ctx = ctx; this.themeDirectory = themeDirectory; } /** * Don't look for classes to satisfy the request, just property files. */ @Override public List<String> getFormats(String baseName) { return FORMAT_PROPERTIES; } /** * Don't look in the class path, look in the current servlet context, in * the bundle directory under the theme directory and in the bundle * directory under the application directory. */ @Override public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload) throws IllegalAccessException, InstantiationException, IOException { checkArguments(baseName, locale, format); log.debug("Creating bundle for '" + baseName + "', " + locale + ", '" + format + "', " + reload); String bundleName = toBundleName(baseName, locale); if (bundleName == null) { throw new NullPointerException("bundleName may not be null."); } String themeI18nPath = "/" + themeDirectory + BUNDLE_DIRECTORY; String appI18nPath = "/" + BUNDLE_DIRECTORY; log.debug("Paths are '" + themeI18nPath + "' and '" + appI18nPath + "'"); return VitroResourceBundle.getBundle(bundleName, ctx, appI18nPath, themeI18nPath, this); } /** * When creating the chain of acceptable Locales, include approximate * matches before giving up and using the root Locale. * * Check the list of supported Locales to see if any have the same * language but different region. If we find any, sort them and insert * them into the usual result list, just before the root Locale. */ @Override public List<Locale> getCandidateLocales(String baseName, Locale locale) { // Find the list of Locales that would normally be returned. List<Locale> usualList = super.getCandidateLocales(baseName, locale); // If our "selectable locales" include no approximate matches that // are not already in the list, we're done. SortedSet<Locale> approximateMatches = findApproximateMatches(locale); approximateMatches.removeAll(usualList); if (approximateMatches.isEmpty()) { return usualList; } // Otherwise, insert those approximate matches into the list just // before the ROOT locale. List<Locale> mergedList = new LinkedList<>(usualList); int rootLocaleHere = mergedList.indexOf(Locale.ROOT); if (rootLocaleHere == -1) { mergedList.addAll(approximateMatches); } else { mergedList.addAll(rootLocaleHere, approximateMatches); } return mergedList; } private SortedSet<Locale> findApproximateMatches(Locale locale) { SortedSet<Locale> set = new TreeSet<>(new LocaleComparator()); for (Locale l : SelectedLocale.getSelectableLocales(ctx)) { if (locale.getLanguage().equals(l.getLanguage())) { set.add(l); } } return set; } /** * The documentation for ResourceBundle.Control.newBundle() says I * should throw these exceptions. */ private void checkArguments(String baseName, Locale locale, String format) { if (baseName == null) { throw new NullPointerException("baseName may not be null."); } if (locale == null) { throw new NullPointerException("locale may not be null."); } if (format == null) { throw new NullPointerException("format may not be null."); } if (!FORMAT_DEFAULT.contains(format)) { throw new IllegalArgumentException("format must be one of these: " + FORMAT_DEFAULT); } } } private static class LocaleComparator implements Comparator<Locale> { @Override public int compare(Locale o1, Locale o2) { int c = o1.getLanguage().compareTo(o2.getLanguage()); if (c == 0) { c = o1.getCountry().compareTo(o2.getCountry()); if (c == 0) { c = o1.getVariant().compareTo(o2.getVariant()); } } return c; } } }