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 }