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 }