NIHVIVO-4011 Create the I18n framework for multi-language support.

This commit is contained in:
j2blake 2013-01-09 18:16:37 -05:00
parent 885bbabdae
commit caf16c392b
8 changed files with 712 additions and 0 deletions

View file

@ -19,6 +19,7 @@ import edu.cornell.mannlib.vitro.webapp.config.ConfigurationProperties;
import edu.cornell.mannlib.vitro.webapp.config.RevisionInfoBean;
import edu.cornell.mannlib.vitro.webapp.controller.freemarker.UrlBuilder.Route;
import edu.cornell.mannlib.vitro.webapp.edit.n3editing.configuration.EditConfigurationConstants;
import edu.cornell.mannlib.vitro.webapp.i18n.freemarker.I18nMethodModel;
import edu.cornell.mannlib.vitro.webapp.web.directives.IndividualShortViewDirective;
import edu.cornell.mannlib.vitro.webapp.web.methods.IndividualLocalNameMethod;
import edu.cornell.mannlib.vitro.webapp.web.methods.IndividualPlaceholderImageUrlMethod;
@ -166,6 +167,7 @@ public class FreemarkerConfiguration extends Configuration {
map.put("profileUrl", new IndividualProfileUrlMethod());
map.put("localName", new IndividualLocalNameMethod());
map.put("placeholderImageUrl", new IndividualPlaceholderImageUrlMethod());
map.put("i18n", new I18nMethodModel());
return map;
}

View file

@ -51,6 +51,10 @@ public class TemplateProcessingHelper {
env.setCustomAttribute("request", request);
env.setCustomAttribute("context", context);
// Set the Locale from the request into the environment, so date builtins will be
// Locale-dependent
env.setLocale(request.getLocale());
// Define a setup template to be included by every page template
String templateType = (String) map.get("templateType");
if (FreemarkerHttpServlet.PAGE_TEMPLATE_TYPE.equals(templateType)) {

View file

@ -0,0 +1,228 @@
/* $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.List;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.ResourceBundle.Control;
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.config.ConfigurationProperties;
import edu.cornell.mannlib.vitro.webapp.controller.VitroRequest;
/**
* 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";
private static final String PROPERTY_DEVELOPER_DEFEAT_CACHE = "developer.defeatI18nCache";
/**
* 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 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.
*/
private I18nBundle getBundle(String bundleName, HttpServletRequest req) {
log.debug("Getting bundle '" + bundleName + "'");
try {
checkDevelopmentMode(req);
checkForChangeInThemeDirectory(req);
String dir = themeDirectory.get();
ServletContext ctx = req.getSession().getServletContext();
ResourceBundle.Control control = getControl(ctx, dir);
ResourceBundle rb = ResourceBundle.getBundle(bundleName,
req.getLocale(), control);
return new I18nBundle(bundleName, rb);
} catch (MissingResourceException e) {
log.warn("Didn't find text bundle '" + bundleName + "'");
return I18nBundle.emptyBundle(bundleName);
} catch (Exception e) {
log.error("Failed to create text bundle '" + bundleName + "'", e);
return I18nBundle.emptyBundle(bundleName);
}
}
/**
* If we are in development mode, clear the cache on each request.
*/
private void checkDevelopmentMode(HttpServletRequest req) {
ConfigurationProperties bean = ConfigurationProperties.getBean(req);
String flag = bean
.getProperty(PROPERTY_DEVELOPER_DEFEAT_CACHE, "false");
if (Boolean.valueOf(flag.trim())) {
log.debug("In development mode - clearing the cache.");
ResourceBundle.clearCache();
}
}
/**
* 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.");
ResourceBundle.clearCache();
}
}
/**
* Override this method in the unit tests, to return a more testable Control
* instance.
*/
protected Control getControl(ServletContext ctx, String dir) {
return new ThemeBasedControl(ctx, dir);
}
// ----------------------------------------------------------------------
// Control classes for instantiating ResourceBundles
// ----------------------------------------------------------------------
/**
* Instead of looking in the classpath, look in the theme directory.
*/
private 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.
*/
@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 VivoResourceBundle.getBundle(bundleName, ctx, appI18nPath,
themeI18nPath, this);
}
/**
* 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);
}
}
}
}

View file

@ -0,0 +1,102 @@
/* $This file is distributed under the terms of the license in /doc/license.txt$ */
package edu.cornell.mannlib.vitro.webapp.i18n;
import java.text.MessageFormat;
import java.util.Collections;
import java.util.Enumeration;
import java.util.ResourceBundle;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
* A wrapper for a ResourceBundle that will not throw an exception, no matter
* what string you request.
*
* If the ResourceBundle was not found, or if it doesn't contain the requested
* key, an error message string is returned, to help the developer diagnose the
* problem.
*/
public class I18nBundle {
private static final Log log = LogFactory.getLog(I18nBundle.class);
private static final String MESSAGE_BUNDLE_NOT_FOUND = "Text bundle ''{0}'' not found.";
private static final String MESSAGE_KEY_NOT_FOUND = "Text bundle ''{0}'' has no text for ''{1}''";
public static I18nBundle emptyBundle(String bundleName) {
return new I18nBundle(bundleName);
}
private final String bundleName;
private final ResourceBundle resources;
private final String notFoundMessage;
private I18nBundle(String bundleName) {
this(bundleName, new EmptyResourceBundle(), MESSAGE_BUNDLE_NOT_FOUND);
}
public I18nBundle(String bundleName, ResourceBundle resources) {
this(bundleName, resources, MESSAGE_KEY_NOT_FOUND);
}
private I18nBundle(String bundleName, ResourceBundle resources, String notFoundMessage) {
if (bundleName == null) {
throw new IllegalArgumentException("bundleName may not be null");
}
if (bundleName.isEmpty()) {
throw new IllegalArgumentException("bundleName may not be empty");
}
if (resources == null) {
throw new NullPointerException("resources may not be null.");
}if (notFoundMessage == null) {
throw new NullPointerException("notFoundMessage may not be null.");
}
this.bundleName = bundleName;
this.resources = resources;
this.notFoundMessage = notFoundMessage;
}
public String text(String key, Object... parameters) {
log.debug("Asking for '" + key + "' from bundle '" + bundleName + "'");
String textString;
if (resources.containsKey(key)) {
textString = resources.getString(key);
return formatString(textString, parameters);
} else {
String message = MessageFormat.format(notFoundMessage, bundleName,
key);
log.warn(message);
return "ERROR: " + message;
}
}
private static String formatString(String textString, Object... parameters) {
if (parameters.length == 0) {
return textString;
} else {
return MessageFormat.format(textString, parameters);
}
}
/**
* A resource bundle that contains no strings.
*/
public static class EmptyResourceBundle extends ResourceBundle {
@Override
public Enumeration<String> getKeys() {
return Collections.enumeration(Collections.<String> emptySet());
}
@Override
protected Object handleGetObject(String key) {
if (key == null) {
throw new NullPointerException("key may not be null.");
}
return null;
}
}
}

View file

@ -0,0 +1,207 @@
/* $This file is distributed under the terms of the license in /doc/license.txt$ */
package edu.cornell.mannlib.vitro.webapp.i18n;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.Enumeration;
import java.util.Properties;
import java.util.ResourceBundle;
import javax.servlet.ServletContext;
import org.apache.commons.io.FileUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
* Works like a PropertyResourceBundle with two exceptions:
*
* It looks for the file in both the i18n directory of the theme and in the i18n
* directory of the application. Properties found in the theme override those
* found in the application.
*
* It allows a property to take its contents from a file. File paths are
* relative to the i18n directory. Again, a file in the theme will override one
* in the application.
*
* If a property has a value (after overriding) of "@@file <filepath>", the
* bundle looks for the file relative to the i18n directory of the theme, then
* relative to the i18n directory of the application. If the file is not found
* in either location, a warning is written to the log and the property will
* contain an error message for displayed.
*
* Note that the filename is not manipulated for Locale, so the author of the
* properties files must do it explicitly. For example:
*
* In all.properties: account_email_html = @@file accountEmail.html
*
* In all_es.properties: account_email_html = @@file accountEmail_es.html
*/
public class VivoResourceBundle extends ResourceBundle {
private static final Log log = LogFactory.getLog(VivoResourceBundle.class);
private static final String FILE_FLAG = "@@file ";
private static final String MESSAGE_FILE_NOT_FOUND = "File {1} not found for property {0}.";
// ----------------------------------------------------------------------
// Factory method
// ----------------------------------------------------------------------
public static VivoResourceBundle getBundle(String bundleName,
ServletContext ctx, String appI18nPath, String themeI18nPath,
Control control) {
try {
return new VivoResourceBundle(bundleName, ctx, appI18nPath,
themeI18nPath, control);
} catch (FileNotFoundException e) {
log.debug(e);
return null;
} catch (Exception e) {
log.warn(e, e);
return null;
}
}
// ----------------------------------------------------------------------
// The instance
// ----------------------------------------------------------------------
private final String bundleName;
private final ServletContext ctx;
private final String appI18nPath;
private final String themeI18nPath;
private final Control control;
private final Properties defaults;
private final Properties properties;
private VivoResourceBundle(String bundleName, ServletContext ctx,
String appI18nPath, String themeI18nPath, Control control)
throws IOException {
this.bundleName = bundleName;
this.ctx = ctx;
this.appI18nPath = appI18nPath;
this.themeI18nPath = themeI18nPath;
this.control = control;
this.defaults = new Properties();
this.properties = new Properties(this.defaults);
loadProperties();
loadReferencedFiles();
}
private void loadProperties() throws IOException {
String resourceName = control.toResourceName(bundleName, "properties");
String defaultsPath = joinPath(appI18nPath, resourceName);
String propertiesPath = joinPath(themeI18nPath, resourceName);
File defaultsFile = locateFile(defaultsPath);
File propertiesFile = locateFile(propertiesPath);
if ((defaultsFile == null) && (propertiesFile == null)) {
throw new FileNotFoundException("Property file not found at '"
+ defaultsPath + "' or '" + propertiesPath + "'");
}
if (defaultsFile != null) {
log.debug("Loading bundle '" + bundleName + "' defaults from '"
+ defaultsPath + "'");
FileInputStream stream = new FileInputStream(defaultsFile);
try {
this.defaults.load(stream);
} finally {
stream.close();
}
}
if (propertiesFile != null) {
log.debug("Loading bundle '" + bundleName + "' overrides from '"
+ propertiesPath + "'");
FileInputStream stream = new FileInputStream(propertiesFile);
try {
this.properties.load(stream);
} finally {
stream.close();
}
}
}
private void loadReferencedFiles() throws IOException {
for (String key : this.properties.stringPropertyNames()) {
String value = this.properties.getProperty(key);
if (value.startsWith(FILE_FLAG)) {
String filepath = value.substring(FILE_FLAG.length()).trim();
loadReferencedFile(key, filepath);
}
}
}
private void loadReferencedFile(String key, String filepath)
throws IOException {
String appFilePath = joinPath(appI18nPath, filepath);
String themeFilePath = joinPath(themeI18nPath, filepath);
File appFile = locateFile(appFilePath);
File themeFile = locateFile(themeFilePath);
if (themeFile != null) {
this.properties.setProperty(key,
FileUtils.readFileToString(themeFile, "UTF-8"));
} else if (appFile != null) {
this.properties.setProperty(key,
FileUtils.readFileToString(appFile, "UTF-8"));
} else {
String message = MessageFormat.format(MESSAGE_FILE_NOT_FOUND, key,
themeFilePath, appFilePath);
this.properties.setProperty(key, message);
log.warn(message);
}
}
private String joinPath(String root, String twig) {
if ((root.charAt(root.length() - 1) == File.separatorChar)
|| (twig.charAt(0) == File.separatorChar)) {
return root + twig;
} else {
return root + File.separatorChar + twig;
}
}
private File locateFile(String path) {
String realPath = ctx.getRealPath(path);
if (realPath == null) {
log.debug("No real path for '" + path + "'");
return null;
}
File f = new File(realPath);
if (!f.isFile()) {
log.debug("No file at '" + realPath + "'");
return null;
}
if (!f.canRead()) {
log.error("Can't read the file at '" + realPath + "'");
return null;
}
log.debug("Located file '" + path + "' at '" + realPath + "'");
return f;
}
@SuppressWarnings("unchecked")
@Override
public Enumeration<String> getKeys() {
return (Enumeration<String>) this.properties.propertyNames();
}
@Override
protected Object handleGetObject(String key) {
String value = this.properties.getProperty(key);
if (value == null) {
log.debug(bundleName + " has no value for '" + key + "'");
}
return value;
}
}

View file

@ -0,0 +1,40 @@
/* $This file is distributed under the terms of the license in /doc/license.txt$ */
package edu.cornell.mannlib.vitro.webapp.i18n.freemarker;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import edu.cornell.mannlib.vitro.webapp.i18n.I18nBundle;
import freemarker.template.TemplateHashModel;
import freemarker.template.TemplateModel;
import freemarker.template.TemplateModelException;
/**
* For Freemarker, this acts like a bundle of text strings. It is simply a
* wrapper around an I18nBundle.
*/
public class I18nBundleTemplateModel implements TemplateHashModel {
private static final Log log = LogFactory
.getLog(I18nBundleTemplateModel.class);
private final String bundleName;
private final I18nBundle textBundle;
public I18nBundleTemplateModel(String bundleName, I18nBundle textBundle) {
this.bundleName = bundleName;
this.textBundle = textBundle;
}
@Override
public TemplateModel get(String key) throws TemplateModelException {
return new I18nStringTemplateModel(bundleName, key,
textBundle.text(key));
}
@Override
public boolean isEmpty() throws TemplateModelException {
return false;
}
}

View file

@ -0,0 +1,50 @@
/* $This file is distributed under the terms of the license in /doc/license.txt$ */
package edu.cornell.mannlib.vitro.webapp.i18n.freemarker;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import edu.cornell.mannlib.vitro.webapp.i18n.I18n;
import edu.cornell.mannlib.vitro.webapp.i18n.I18nBundle;
import freemarker.core.Environment;
import freemarker.template.TemplateMethodModel;
import freemarker.template.TemplateModelException;
/**
* This Freemarker method will produce a bundle of text strings. It is simply a
* wrapper around I18n that produces a wrapped I18nBundle.
*
* If the bundle name is not provided, the default bundle is assumed.
*/
public class I18nMethodModel implements TemplateMethodModel {
private static final Log log = LogFactory.getLog(I18nMethodModel.class);
@SuppressWarnings("rawtypes")
@Override
public Object exec(List args) throws TemplateModelException {
if (args.size() > 1) {
throw new TemplateModelException("Too many arguments: "
+ "displayText method only requires a bundle name.");
}
Object arg = args.isEmpty() ? I18n.DEFAULT_BUNDLE_NAME : args.get(0);
if (!(arg instanceof String)) {
throw new IllegalArgumentException(
"Arguments to a TemplateMethodModel are supposed to be Strings!");
}
log.debug("Asking for this bundle: " + arg);
String bundleName = (String) arg;
Environment env = Environment.getCurrentEnvironment();
HttpServletRequest request = (HttpServletRequest) env
.getCustomAttribute("request");
I18nBundle tb = I18n.bundle(bundleName, request);
return new I18nBundleTemplateModel(bundleName, tb);
}
}

View file

@ -0,0 +1,79 @@
/* $This file is distributed under the terms of the license in /doc/license.txt$ */
package edu.cornell.mannlib.vitro.webapp.i18n.freemarker;
import java.text.MessageFormat;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import freemarker.template.TemplateMethodModelEx;
import freemarker.template.TemplateModel;
import freemarker.template.TemplateModelException;
import freemarker.template.TemplateScalarModel;
import freemarker.template.utility.DeepUnwrap;
/**
* A Freemarker representation of a text string. Because it implements
* TemplateScalarModel, you can use it as a string value. And because it
* implements TemplateMethodModel, you can pass arguments to it for formatting.
*
* So if the string is "His name is {0}!", then these references could be used:
*
* ${string} ==> "His name is {0}!"
*
* ${string("Bozo")} ==> "His name is Bozo!"
*
* Note that the format of the message is determined by java.text.MessageFormat,
* so argument indices start at 0 and you can escape a substring by wrapping it
* in apostrophes.
*/
public class I18nStringTemplateModel implements TemplateMethodModelEx,
TemplateScalarModel {
private static final Log log = LogFactory
.getLog(I18nStringTemplateModel.class);
private final String bundleName;
private final String key;
private final String textString;
public I18nStringTemplateModel(String bundleName, String key,
String textString) {
this.bundleName = bundleName;
this.key = key;
this.textString = textString;
}
@Override
public String getAsString() throws TemplateModelException {
return textString;
}
@SuppressWarnings({ "rawtypes", "unchecked" })
@Override
public Object exec(List args) throws TemplateModelException {
log.debug("Formatting string '" + key + "' from bundle '" + bundleName
+ "' with these arguments: " + args);
if (args.isEmpty()) {
return textString;
} else {
Object[] unwrappedArgs = new Object[args.size()];
for (int i = 0; i < args.size(); i++) {
unwrappedArgs[i] = DeepUnwrap.unwrap((TemplateModel) args
.get(i));
}
try {
return MessageFormat.format(textString, unwrappedArgs);
} catch (Exception e) {
String message = "Can't format '" + key + "' from bundle '"
+ bundleName + "', wrong argument types: " + args
+ " for message format'" + textString + "'";
log.warn(message);
return message;
}
}
}
}