package hirondelle.web4j.ui.translate;
import java.util.*;
import hirondelle.web4j.model.ModelCtorException;
import hirondelle.web4j.model.ModelUtil;
import hirondelle.web4j.model.Id;
import hirondelle.web4j.model.Check;
import hirondelle.web4j.security.SafeText;
/**
Model Object for a translation.
<P>This class is provided as a convenience. Implementations of {@link Translator} are not required to
use this class.
<P>As one of its {@link hirondelle.web4j.StartupTasks}, a typical implementation of
{@link Translator} may fetch a {@code List<Translation>} from some source
(usually a database, perhaps some properties files), and keep a cache in memory.
<P><a name="MapStructure"></a>
For looking up translations, the following nested {@link Map} structure is useful :
<PRE>
Map[BaseText, Map[Locale, Translation]]
</PRE>
Here, <tt>BaseText</tt> and <tt>Translation</tt> are ordinary <em>unescaped</em>
Strings, not {@link SafeText}. This is because the various translation tags in this
package always <em>first</em> perform translation using ordinary unescaped Strings, and
<em>then</em> perform any necessary escaping on the result of the translation.
<P>(See {@link Translator} for definition of 'base text'.)
<P>The {@link #asNestedMap(Collection)} method will modify a {@code List<Translation>} into just such a
structure. As well, {@link #lookUp(String, Locale, Map)} provides a simple <em>default</em> method for
performing the typical lookup with such a structure, given base text and target locale.
<h3>Usually String, but sometimes SafeText</h3>
The following style will remain consistent, and will not escape special characters twice :
<ul>
<li>unescaped : translations stored in the database.
<li>escaped : Translation objects (since they use {@link SafeText}). This allows end users
to edit such objects just like any other data, with no danger of scripts executing in their browser.
<li>unescaped : in-memory data, extracted from N <tt>Translation</tt> objects
using {@link SafeText#getRawString()}. This in-memory data implements a
<tt>Translator</tt>. Its data is not rendered <em>directly</em>
in a JSP, so it can remain as String.
<li>escaped : the various translation tags always perform the needed escaping on the raw String.
</ul>
The translation text usually remains as a String, yet {@link SafeText} is available
when working with the data directly in a web page, in a form or listing.
*/
public final class Translation implements Comparable<Translation> {
/**
Constructor with no explicit foreign keys.
@param aBaseText item to be translated (required). See {@link Translator} for definition of 'base text'.
@param aLocale target locale for the translation (required)
@param aTranslation translation of the base text into the target locale (required)
*/
public Translation(SafeText aBaseText, Locale aLocale, SafeText aTranslation) throws ModelCtorException {
fBaseText = aBaseText;
fLocale = aLocale;
fTranslation = aTranslation;
validateState();
}
/**
Constructor with explict foreign keys.
<P>This constructor allows carrying the foreign keys directly, instead of performing lookup later on.
(If the database does not support subselects, then use of this constructor will likely reduce
trivial lookup operations.)
@param aBaseText item to be translated (required). See {@link Translator} for definition of 'base text'.
@param aLocale target locale for the translation (required)
@param aTranslation translation of the base text into the target locale (required)
@param aBaseTextId foreign key representing a <tt>BaseText</tt> item, <tt>1..50</tt> characters (optional)
@param aLocaleId foreign key representing a <tt>Locale</tt>, <tt>1..50</tt> characters (optional)
*/
public Translation(SafeText aBaseText, Locale aLocale, SafeText aTranslation, Id aBaseTextId, Id aLocaleId) throws ModelCtorException {
fBaseText = aBaseText;
fLocale = aLocale;
fTranslation = aTranslation;
fBaseTextId = aBaseTextId;
fLocaleId = aLocaleId;
validateState();
}
/** Return the base text passed to the constructor. */
public SafeText getBaseText() {
return fBaseText;
}
/** Return the locale passed to the constructor. */
public Locale getLocale() {
return fLocale;
}
/** Return the localized translation passed to the constructor. */
public SafeText getTranslation() {
return fTranslation;
}
/** Return the base text id passed to the constructor. */
public Id getBaseTextId(){
return fBaseTextId;
}
/** Return the locale id passed to the constructor. */
public Id getLocaleId(){
return fLocaleId;
}
/**
Return a {@link Map} having a <a href="#MapStructure">structure</a>
typically needed for looking up translations.
<P>The caller will use the returned {@link Map} to look up first using <tt>BaseText</tt>,
and then using <tt>Locale</tt>. See {@link #lookUp(String, Locale, Map)}.
@param aTranslations {@link Collection} of {@link Translation} objects.
@return {@link Map} of a <a href="#MapStructure">structure suitable for looking up translations</a>.
*/
public static Map<String, Map<String, String>> asNestedMap(Collection<Translation> aTranslations){
Map<String, Map<String, String>> result = new LinkedHashMap<String, Map<String, String>>();
String currentBaseText = null;
Map<String, String> currentTranslations = null;
for (Translation trans: aTranslations){
if ( trans.getBaseText().getRawString().equals(currentBaseText) ){
currentTranslations.put(trans.getLocale().toString(), trans.getTranslation().getRawString());
}
else {
//finish old
if (currentBaseText != null) {
result.put(currentBaseText, currentTranslations);
}
//start new
currentBaseText = trans.getBaseText().getRawString();
currentTranslations = new LinkedHashMap<String, String>();
currentTranslations.put(trans.getLocale().toString(), trans.getTranslation().getRawString());
}
}
//ensure last one is added
if(currentBaseText != null && currentTranslations != null){
result.put(currentBaseText, currentTranslations);
}
return result;
}
/**
Look up a translation using a simple policy.
<P>If <tt>aBaseText</tt> is not known, or if there is no <em>explicit</em> translation for
the exact {@link Locale}, then return <tt>aBaseText</tt> as is, without translation or
alteration.
<P>The policy used here is simple. It may not be desirable for some applications.
In particular, if there is a need to implement a "best match" to <tt>aLocale</tt>
(after the style of {@link ResourceBundle}), then this method cannot be used.
@param aBaseText text to be translated. See {@link Translator} for a definition of 'base text'.
@param aLocale whose <tt>toString</tt> result will be used to find the localized
translation of <tt>aBaseText</tt>.
@param aTranslations has the <a href="#MapStructure">structure suitable for look up</a>.
@return {@link LookupResult} carrying the text of the successful translation, or, in the case of a failed lookup, information
about the nature of the failure.
*/
public static LookupResult lookUp(String aBaseText, Locale aLocale, Map<String, Map<String, String>> aTranslations) {
LookupResult result = null;
Map<String, String> allTranslations = aTranslations.get(aBaseText);
if ( allTranslations == null ) {
result = LookupResult.UNKNOWN_BASE_TEXT;
}
else {
String translation = allTranslations.get(aLocale.toString());
result = (translation != null) ? new LookupResult(translation): LookupResult.UNKNOWN_LOCALE;
}
return result;
}
/**
The result of {@link Translation#lookUp(String, Locale, Map)}.
<P>Encapsulates both the species of success/fail and the actual
text of the translation, if any.
<P>Example of a typical use case :
<PRE>
String text = null;
LookupResult lookup = Translation.lookUp(aBaseText, aLocale, fTranslations);
if( lookup.hasSucceeded() ){
text = lookup.getText();
}
else {
text = aBaseText;
if(LookupResult.UNKNOWN_BASE_TEXT == lookup){
addToListOfUnknowns(aBaseText);
}
else if (LookupResult.UNKNOWN_LOCALE == lookup){
//do nothing in this implementation
}
}
</PRE>
*/
public static final class LookupResult {
/** <tt>BaseText</tt> is unknown. */
public static final LookupResult UNKNOWN_BASE_TEXT = new LookupResult();
/** <tt>BaseText</tt> is known, but no translation exists for the specified <tt>Locale</tt>*/
public static final LookupResult UNKNOWN_LOCALE = new LookupResult();
/** Returns <tt>true</tt> only if a specific translation exists for <tt>BaseText</tt> and <tt>Locale</tt>. */
public boolean hasSucceeded(){ return fTranslationText != null; }
/**
Return the text of the successful translation.
Returns <tt>null</tt> only if {@link #hasSucceeded()} is <tt>false</tt>.
*/
public String getText(){ return fTranslationText; }
LookupResult(String aTranslation){
fTranslationText = aTranslation;
}
private final String fTranslationText;
private LookupResult(){
fTranslationText = null;
}
}
/** Intended for debugging only. */
@Override public String toString(){
return ModelUtil.toStringFor(this);
}
public int compareTo(Translation aThat) {
final int EQUAL = 0;
if ( this == aThat ) return EQUAL;
int comparison = this.fBaseText.compareTo(aThat.fBaseText);
if ( comparison != EQUAL ) return comparison;
comparison = this.fLocale.toString().compareTo(aThat.fLocale.toString());
if ( comparison != EQUAL ) return comparison;
comparison = this.fTranslation.compareTo(aThat.fTranslation);
if ( comparison != EQUAL ) return comparison;
return EQUAL;
}
@Override public boolean equals(Object aThat){
Boolean result = ModelUtil.quickEquals(this, aThat);
if ( result == null ) {
Translation that = (Translation)aThat;
result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
}
return result;
}
@Override public int hashCode() {
if ( fHashCode == 0 ){
fHashCode = ModelUtil.hashCodeFor(getSignificantFields());
}
return fHashCode;
}
// PRIVATE //
private final SafeText fBaseText;
private final Locale fLocale;
private final SafeText fTranslation;
private Id fLocaleId;
private Id fBaseTextId;
private int fHashCode;
private void validateState() throws ModelCtorException {
ModelCtorException ex = new ModelCtorException();
if( ! Check.required(fBaseText) ) {
ex.add("Base Text must have content.");
}
if( ! Check.required(fLocale) ) {
ex.add("Locale must have content.");
}
if( ! Check.required(fTranslation) ) {
ex.add("Translation must have content.");
}
if( ! Check.optional(fLocaleId, Check.min(1), Check.max(50)) ){
ex.add("LocaleId optional, 1..50 characters.");
}
if( ! Check.optional(fBaseTextId, Check.min(1), Check.max(50)) ){
ex.add("BaseTextId optional, 1..50 characters.");
}
if( ex.isNotEmpty() ) throw ex;
}
private Object[] getSignificantFields(){
return new Object[] {fBaseText, fLocale, fTranslation};
}
}