001    package hirondelle.web4j.ui.translate;
002    
003    import java.util.*;
004    import hirondelle.web4j.model.ModelCtorException;
005    import hirondelle.web4j.model.ModelUtil;
006    import hirondelle.web4j.model.Id;
007    import hirondelle.web4j.model.Check;
008    import hirondelle.web4j.security.SafeText;
009    
010    /**
011     Model Object for a translation.
012     
013     <P>This class is provided as a convenience. Implementations of {@link Translator} are not required to 
014     use this class.
015     
016     <P>As one of its {@link hirondelle.web4j.StartupTasks}, a typical implementation of 
017     {@link Translator} may fetch a {@code List<Translation>} from some source 
018     (usually a database, perhaps some properties files), and keep a cache in memory.
019     
020     <P><a name="MapStructure"></a>
021     For looking up translations, the following nested {@link Map} structure is useful : 
022     <PRE>
023       Map[BaseText, Map[Locale, Translation]]
024     </PRE>
025     Here, <tt>BaseText</tt> and <tt>Translation</tt> are ordinary <em>unescaped</em> 
026     Strings, not {@link SafeText}. This is because the various translation tags in this 
027     package always <em>first</em> perform translation using ordinary unescaped Strings, and 
028     <em>then</em> perform any necessary escaping on the result of the translation.
029     
030     <P>(See {@link Translator} for definition of 'base text'.)
031     
032     <P>The {@link #asNestedMap(Collection)} method will modify a {@code List<Translation>} into just such a 
033     structure. As well, {@link #lookUp(String, Locale, Map)} provides a simple <em>default</em> method for 
034     performing the typical lookup with such a structure, given base text and target locale.
035    
036     <h3>Usually String, but sometimes SafeText</h3>
037     The following style will remain consistent, and will not escape special characters twice :
038    <ul>
039     <li>unescaped : translations stored in the database. 
040     <li>escaped : Translation objects (since they use {@link SafeText}). This allows end users 
041     to edit such objects just like any other data, with no danger of scripts executing in their browser.  
042     <li>unescaped : in-memory data, extracted from N <tt>Translation</tt> objects 
043     using {@link SafeText#getRawString()}. This in-memory data implements a  
044     <tt>Translator</tt>. Its data is not rendered <em>directly</em>
045     in a JSP, so it can remain as String.
046     <li>escaped : the various translation tags always perform the needed escaping on the raw String. 
047    </ul> 
048    
049     The translation text usually remains as a String, yet {@link SafeText} is available  
050     when working with the data directly in a web page, in a form or listing.
051    */
052    public final class Translation implements Comparable<Translation> {
053      
054      /**
055       Constructor with no explicit foreign keys.
056       
057       @param aBaseText item to be translated (required). See {@link Translator} for definition of 'base text'. 
058       @param aLocale target locale for the translation (required) 
059       @param aTranslation translation of the base text into the target locale (required)
060      */
061      public Translation(SafeText aBaseText, Locale aLocale, SafeText aTranslation) throws ModelCtorException {
062        fBaseText = aBaseText;
063        fLocale = aLocale;
064        fTranslation = aTranslation;
065        validateState();
066      }
067    
068      /**
069       Constructor with explict foreign keys.
070       
071       <P>This constructor allows carrying the foreign keys directly, instead of performing lookup later on. 
072       (If the database does not support subselects, then use of this constructor will likely reduce 
073       trivial lookup operations.)
074        
075       @param aBaseText item to be translated (required). See {@link Translator} for definition of 'base text'. 
076       @param aLocale target locale for the translation (required) 
077       @param aTranslation translation of the base text into the target locale (required)
078       @param aBaseTextId foreign key representing a <tt>BaseText</tt> item, <tt>1..50</tt> characters (optional)
079       @param aLocaleId foreign key representing a <tt>Locale</tt>, <tt>1..50</tt> characters (optional)
080      */
081      public Translation(SafeText aBaseText, Locale aLocale, SafeText aTranslation, Id aBaseTextId, Id aLocaleId) throws ModelCtorException {
082        fBaseText = aBaseText;
083        fLocale = aLocale;
084        fTranslation = aTranslation;
085        fBaseTextId = aBaseTextId;
086        fLocaleId = aLocaleId;
087        validateState();
088      }
089      
090      /** Return the base text passed to the constructor.  */
091      public SafeText getBaseText() {
092        return fBaseText;
093      }
094    
095      /** Return the locale passed to the constructor.  */
096      public Locale getLocale() {
097        return fLocale;
098      }
099    
100      /** Return the localized translation passed to the constructor.  */
101      public SafeText getTranslation() {
102        return fTranslation;
103      }
104      
105      /** Return the base text id passed to the constructor.  */
106      public Id getBaseTextId(){
107        return fBaseTextId;
108      }
109      
110      /** Return the locale id passed to the constructor.  */
111      public Id getLocaleId(){
112        return fLocaleId;
113      }
114      
115      /**
116       Return a {@link Map} having a <a href="#MapStructure">structure</a> 
117       typically needed for looking up translations.
118       
119       <P>The caller will use the returned {@link Map} to look up first using <tt>BaseText</tt>, 
120       and then using <tt>Locale</tt>. See {@link #lookUp(String, Locale, Map)}.
121       
122       @param aTranslations {@link Collection} of {@link Translation} objects.
123       @return {@link Map} of a <a href="#MapStructure">structure suitable for looking up translations</a>.
124      */
125      public static Map<String, Map<String, String>> asNestedMap(Collection<Translation> aTranslations){
126         Map<String, Map<String, String>> result = new LinkedHashMap<String, Map<String, String>>();
127         String currentBaseText = null;
128         Map<String, String> currentTranslations = null;
129         for (Translation trans: aTranslations){
130           if ( trans.getBaseText().getRawString().equals(currentBaseText) ){
131             currentTranslations.put(trans.getLocale().toString(), trans.getTranslation().getRawString());
132           }
133           else {
134             //finish old
135             if (currentBaseText != null) {
136               result.put(currentBaseText, currentTranslations);
137             }
138             //start new
139             currentBaseText = trans.getBaseText().getRawString();
140             currentTranslations = new LinkedHashMap<String, String>();
141             currentTranslations.put(trans.getLocale().toString(), trans.getTranslation().getRawString());
142           }
143         }
144         //ensure last one is added
145         if(currentBaseText != null && currentTranslations != null){
146           result.put(currentBaseText, currentTranslations);
147         }
148         return result;
149      }
150      
151      /**
152       Look up a translation using a simple policy.
153       
154       <P>If <tt>aBaseText</tt> is not known, or if there is no <em>explicit</em> translation for 
155       the exact {@link Locale}, then return <tt>aBaseText</tt> as is, without translation or 
156       alteration.
157       
158       <P>The policy used here is simple. It may not be desirable for some applications.
159       In particular, if there is a need to implement a "best match" to <tt>aLocale</tt> 
160       (after the style of {@link ResourceBundle}), then this method cannot be used. 
161        
162       @param aBaseText text to be translated. See {@link Translator} for a definition of 'base text'.
163       @param aLocale whose <tt>toString</tt> result will be used to find the localized 
164       translation of <tt>aBaseText</tt>.
165       @param aTranslations has the <a href="#MapStructure">structure suitable for look up</a>.
166       @return {@link LookupResult} carrying the text of the successful translation, or, in the case of a failed lookup, information
167       about the nature of the failure.
168      */
169      public static LookupResult lookUp(String aBaseText, Locale aLocale, Map<String, Map<String, String>> aTranslations) {
170        LookupResult result = null;
171        Map<String, String> allTranslations = aTranslations.get(aBaseText);
172        if ( allTranslations == null ) {
173          result = LookupResult.UNKNOWN_BASE_TEXT;
174        }
175        else {
176          String translation = allTranslations.get(aLocale.toString());
177          result = (translation != null) ? new LookupResult(translation): LookupResult.UNKNOWN_LOCALE; 
178        }
179        return result;
180      }
181      
182      /** 
183       The result of {@link Translation#lookUp(String, Locale, Map)}.
184       <P>Encapsulates both the species of success/fail and the actual 
185       text of the translation, if any.
186       
187      <P>Example of a typical use case :
188      <PRE>
189        String text = null;
190        LookupResult lookup = Translation.lookUp(aBaseText, aLocale, fTranslations);
191        if( lookup.hasSucceeded() ){ 
192          text = lookup.getText();
193        }
194        else {
195          text = aBaseText;
196          if(LookupResult.UNKNOWN_BASE_TEXT == lookup){
197            addToListOfUnknowns(aBaseText);
198          }
199          else if (LookupResult.UNKNOWN_LOCALE == lookup){
200            //do nothing in this implementation
201          }
202        }
203      </PRE>
204      */
205      public static final class LookupResult {
206        /** <tt>BaseText</tt> is unknown. */
207        public static final LookupResult UNKNOWN_BASE_TEXT = new LookupResult(); 
208        /** <tt>BaseText</tt> is known, but no translation exists for the specified <tt>Locale</tt>*/
209        public static final LookupResult UNKNOWN_LOCALE = new LookupResult();
210        /** Returns <tt>true</tt> only if a specific translation exists for <tt>BaseText</tt> and <tt>Locale</tt>.  */
211        public boolean hasSucceeded(){ return fTranslationText != null; }
212        /**
213         Return the text of the successful translation.  
214         Returns <tt>null</tt> only if {@link #hasSucceeded()} is <tt>false</tt>. 
215        */
216        public String getText(){ return fTranslationText; }
217        LookupResult(String aTranslation){ 
218          fTranslationText = aTranslation; 
219        }
220        private final String fTranslationText;
221        private LookupResult(){  
222          fTranslationText = null;
223        }
224      }
225    
226      /** Intended for debugging only. */
227      @Override public String toString(){
228        return ModelUtil.toStringFor(this);
229      }
230    
231      public int compareTo(Translation aThat) {
232        final int EQUAL = 0;
233        if ( this == aThat ) return EQUAL;
234        
235        int comparison = this.fBaseText.compareTo(aThat.fBaseText);
236        if ( comparison != EQUAL ) return comparison;
237        
238        comparison = this.fLocale.toString().compareTo(aThat.fLocale.toString());
239        if ( comparison != EQUAL ) return comparison;    
240        
241        comparison = this.fTranslation.compareTo(aThat.fTranslation);
242        if ( comparison != EQUAL ) return comparison;    
243        
244        return EQUAL;
245      }
246      
247      @Override public boolean equals(Object aThat){
248        Boolean result = ModelUtil.quickEquals(this, aThat);
249        if ( result == null ) {
250          Translation that = (Translation)aThat;
251          result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
252        }
253        return result;
254      }
255      
256      @Override public int hashCode() {
257        if ( fHashCode == 0 ){
258          fHashCode = ModelUtil.hashCodeFor(getSignificantFields());
259        }
260        return fHashCode;
261      }
262      
263      // PRIVATE //
264      private final SafeText fBaseText;
265      private final Locale fLocale;
266      private final SafeText fTranslation;
267      private Id fLocaleId; 
268      private Id fBaseTextId;
269      private int fHashCode;
270      
271      private void validateState() throws ModelCtorException {
272        ModelCtorException ex = new ModelCtorException();
273        if( ! Check.required(fBaseText) ) {
274          ex.add("Base Text must have content.");      
275        }
276        if( ! Check.required(fLocale) ) {
277          ex.add("Locale must have content.");      
278        }
279        if( ! Check.required(fTranslation) ) {
280          ex.add("Translation must have content.");
281        }
282        if( ! Check.optional(fLocaleId, Check.min(1), Check.max(50)) ){
283          ex.add("LocaleId optional, 1..50 characters.");
284        }
285        if( ! Check.optional(fBaseTextId, Check.min(1), Check.max(50)) ){
286          ex.add("BaseTextId optional, 1..50 characters.");
287        }
288        if( ex.isNotEmpty() )  throw ex;
289      }
290      
291      private Object[] getSignificantFields(){
292        return new Object[] {fBaseText, fLocale, fTranslation};
293      }
294    }