/* vim: set ts=2 et sw=2 cindent fo=qroca: */
package com.globant.katari.core.spring;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.Validate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.support
.ReloadableResourceBundleMessageSource;
/** A message source that is designed to make translations modularized and
* extensible.
*
* This class works similarly to ReloadableResourceBundleMessageSource, but
* with some important differentes:
*
* - The locale resolution is extended by looking for files in a directory
* based on the locale name.
*
* - This message source resolves messages in the parent message source first.
* Each katari module may provide messages in the languages the author sees
* fit. And integrators have the choice of overriding any message by adding
* the code to the parent message source.
*
* - When resolving a message in the parent, the code is looked up by first
* prefixing it with the module name and then the plain code.
*
* - You can 'link' message sources by declaring 'dependencies' between them.
* If a message is not found in the current message source, it looks for it
* in all its dependencies. This is intended to allow a module to resolve a
* message that is declared in another module.
*
* - In debug mode, when a client wants a message from the message code, this
* message source first looks for it in a file with an url of the form
* file:[debugPrefx]/basename. The message source checks the modification
* date of this file each time.
*
* A typical debug prefix is ../katari-local-login/src/main/resources.
*
* This resolution on the file system is not guaranteed to work when
* fallbackToSystemLocale is true. This class defaults fallbackToSystemLocale
* to false.
*
* To clarify, assume that you have a message source with:
*
* - basename = com/globant/lang/msg
*
* - debug = true
*
* - debugPrefix = src/main/resources.
*
* And a parent message source with:
*
* - basename = lang/msg
*
* - debug = true
*
* - debugPrefix = ../src/main/webapp/WEB-INF/
*
* To resolve a message code C in the locale es_SP, the process is:
*
* - First look in the parent:
*
* - Look for C in src/main/webapp/WEB-INF/lang_es_SP/msg.properties
*
* - Look for C in src/main/webapp/WEB-INF/lang/msg_es_SP.properties
*
* - Look for C in src/main/webapp/WEB-INF/lang_es/msg.properties
*
* - Look for C in src/main/webapp/WEB-INF/lang/msg_es.properties
*
* - Look for C in lang_es_SP/msg.properties
*
* - Look for C in lang/msg_es_SP.properties
*
* - Look for C in lang_es/msg.properties
*
* - Look for C in lang/msg_es.properties
*
* - Look in the message source:
*
* - Look for C in src/main/resources/com/globant/lang_es_SP/msg.properties
*
* - Look for C in src/main/resources/com/globant/lang/msg_es_SP.properties
*
* - Look for C in src/main/resources/com/globant/lang_es/msg.properties
*
* - Look for C in src/main/resources/com/globant/lang/msg_es.properties
*
* - Look for C in com/globant/lang_es_SP/msg.properties
*
* - Look for C in com/globant/lang/msg_es_SP.properties
*
* - Look for C in com/globant/lang_es/msg.properties
*
* - Look for C in com/globant/lang/msg_es.properties
*
* Then, repeat the process for the locale selected as default.
*
* Finally, repeat the process in each of the dependencies.
*
* This way of resolving messages allows:
*
* - Module writers to support a set of locales (with msg_[locale].properties).
*
* - 3rd parties to add additional languages to a module (with
* lang_[locale]/messages.properties.
*
* - 3rd parties to redefine any translation.
*
* - Module integrators to support additional languages and override any
* translation.
*
* NOTE: the dependencies mechanism is not yet fully tested and it has many
* limitations, the main one being that the 'namespace' of the message names is
* shared between all message sources. So there is a chance that a message
* defined in a dependent module may be resolved as a different, non intended
* value.
*
* Another limitation is that the dependency mechanism comes into play only
* after looking for the message in every relevant locale.
*/
public class KatariMessageSource
extends ReloadableResourceBundleMessageSource {
/** The class logger.
*/
private Logger log = LoggerFactory.getLogger(KatariMessageSource.class);
/** The name of the module that this message source belongs to.
*
* This is null for a parent, global, message source.
*/
private String moduleName;
/** The locale to use when the message is not found in the requested locale.
*/
private Locale fallbackLocale = null;
/** Whether debug mode is enabled.
*
* In debug mode, the messages files (normally messages.properties) are first
* search from the file system. This makes it possible to edit messages files
* and see the result without a redeploy. Defaults to false.
*/
private boolean debug = false;
/** A prefix to use to find the resources in the disk as a file.
*
* This is used in debug mode. It is never null.
*/
private String debugPrefix = "file:.";
/** A list of message sources that this message source may depend on.
*
* If a message is neither found in the parent and in this message source,
* look for messages in each of the dependencies.
*/
private final List<KatariMessageSource> dependencies =
new LinkedList<KatariMessageSource>();
/** Constructor to be used for the global (without parent) message source.
*
* @param theFallbackLocale the locale to use when the message is not found
* in the requested locale. It cannot be null.
*/
public KatariMessageSource(final Locale theFallbackLocale) {
Validate.notNull(theFallbackLocale, "The fallback locale cannot be null.");
setFallbackToSystemLocale(false);
fallbackLocale = theFallbackLocale;
}
/** Constructor.
*
* Creates a KatariMessageSource. This constructor is intended to be used in
* modules. It inherits the fallbackLocale from the parent.
*
* @param theModuleName the name of the module. It cannot be null.
*/
public KatariMessageSource(final String theModuleName,
final KatariMessageSource theParent) {
Validate.notNull(theModuleName, "The module name cannot be null.");
Validate.notNull(theParent, "The parent cannot be null.");
setFallbackToSystemLocale(false);
setParentMessageSource(theParent);
moduleName = theModuleName;
fallbackLocale = theParent.fallbackLocale;
}
/** Constructor.
*
* Creates a KatariMessageSource with dependencies. This constructor is
* intended to be used in modules. It inherits the fallbackLocale from the
* parent.
*
* In addition to the module name and parent, this constructor takes a list
* of other message sources that are used the message source being
* constructed cannot resolve the message.
*
* @param theModuleName the name of the module. It cannot be null.
*
* @param theDependencies the dependencies of this message source. It cannot
* be null.
*/
public KatariMessageSource(final String theModuleName,
final KatariMessageSource theParent,
final List<KatariMessageSource> theDependencies) {
Validate.notNull(theModuleName, "The module name cannot be null.");
Validate.notNull(theParent, "The parent cannot be null.");
Validate.notNull(theDependencies, "The dependencies cannot be null.");
setFallbackToSystemLocale(false);
setParentMessageSource(theParent);
moduleName = theModuleName;
fallbackLocale = theParent.fallbackLocale;
dependencies.addAll(theDependencies);
}
/** {@inheritDoc}
*
* Calculates the filenames for the given locale and the fallback locale.
*/
protected List<String> calculateFilenamesForLocale(final String basename,
final Locale locale) {
List<String> filenames = filenamesWithoutFallback(basename, locale);
if (fallbackLocale != null && !locale.equals(fallbackLocale)) {
List<String> fallbacks;
fallbacks = calculateFilenamesForLocale(basename, fallbackLocale);
for (String fallbackFilename : fallbacks) {
if (!filenames.contains(fallbackFilename)) {
filenames.add(fallbackFilename);
}
}
}
return filenames;
}
/** {@inheritDoc}
*
* Calculate the filenames for the given bundle basename and Locale,
* appending language code, country code, and variant code to the directory
* containing the message and the message itself.
*/
protected List<String> filenamesWithoutFallback(final String basename,
final Locale locale) {
log.trace("Entering calculateFilenamesForLocale('{}', '{}')", basename,
locale);
Pattern pattern = Pattern.compile("([^:]+:)?(?:(.*)/)?([^/]+)");
Matcher matcher = pattern.matcher(basename);
if (!matcher.matches()) {
throw new RuntimeException(basename + " does not match " + pattern);
}
String protocol = matcher.group(1);
if (protocol == null) {
protocol = "";
}
String directory = matcher.group(2);
String fileName = matcher.group(3);
if (directory == null) {
directory = "";
} else {
fileName = "/" + fileName;
}
log.debug("dir: '{}', file: '{}'", directory, fileName);
// Directory based locales.
List<String> fileNames = new LinkedList<String>();
List<String> basicFileNames = super.calculateFilenamesForLocale(
directory + fileName, locale);
if (directory.length() != 0) {
List<String> dirnames;
dirnames = super.calculateFilenamesForLocale(directory, locale);
for (String name : dirnames) {
name = name + fileName;
fileNames.add(name);
}
// Merge the directories and file names.
int nameCount = fileNames.size();
for (int i = 0; i < nameCount; ++i) {
fileNames.add(2 * i + 1, basicFileNames.get(i));
}
} else {
fileNames.addAll(basicFileNames);
}
log.debug("File names {}.", fileNames);
// Now, calculate the file system based messages.
List<String> result = new LinkedList<String>();
if (debug) {
result = new LinkedList<String>();
for (String name : fileNames) {
result.add(calculatePrefixedName(name));
}
}
for (String name : fileNames) {
result.add(protocol + name);
}
log.trace("Leaving calculateFilenamesForLocale with {}.", result);
return result;
}
/** Obtains the prefix relative file name of the provided message properties.
*
* This operation can only be called in debug mode.
*
* @param fileName the original name of the file. It cannot be null.
*
* @return a file name that prefixed with the debug prefix, never null.
*/
private String calculatePrefixedName(final String fileName) {
log.trace("Entering calculatePrefixedName");
Validate.isTrue(debug, "Must be in debug mode.");
String result = fileName;
if (result.startsWith("/")) {
result = debugPrefix + result;
} else {
result = debugPrefix + "/" + result;
}
log.trace("Leaving calculatePrefixedName with {}", result);
return result;
}
/** {@inheritDoc}
*
* Overrides the default implementation to first resolve the message in the
* parent message source.
*/
protected String getMessageInternal(final String code, final Object[] args,
final Locale locale) {
String message = null;
// First look in the parent, with the module name.
if (moduleName != null) {
message = getMessageFromParent(moduleName + "." + code, args, locale);
}
// Then look in the parent, withou the module name.
if (message == null) {
message = getMessageFromParent(code, args, locale);
}
// Look in my own messages.
if (message == null) {
message = super.getMessageInternal(code, args, locale);
}
// And finally, if I don't have them, look for the dependencies.
if (message == null) {
for (KatariMessageSource messageSource : dependencies) {
message = messageSource.getMessageInternal(code, args, locale);
}
}
return message;
}
/** Sets the debug mode.
*
* @param debugEnabled true to enable debug mode, false by default.
*/
public void setDebug(final boolean debugEnabled) {
debug = debugEnabled;
if (debug) {
setCacheSeconds(0);
}
}
/** Sets the debug prefix.
*
* @param prefix a prefix to add to the message properties file to look for
* messages in the file system. It is a dot by default. A trailing / is
* removed if present. It cannot be null.
*/
public void setDebugPrefix(final String prefix) {
Validate.notNull(prefix, "The prefix cannot be null.");
if (prefix.endsWith("/")) {
debugPrefix = "file:" + prefix.substring(0, prefix.length() - 1);
} else {
debugPrefix = "file:" + prefix;
}
}
}