001    package hirondelle.web4j.model;
002    
003    import hirondelle.web4j.BuildImpl;
004    import hirondelle.web4j.request.Formats;
005    import hirondelle.web4j.request.RequestParameter;
006    import hirondelle.web4j.ui.translate.Translator;
007    import hirondelle.web4j.util.Args;
008    import hirondelle.web4j.util.EscapeChars;
009    import hirondelle.web4j.util.Util;
010    
011    import java.io.IOException;
012    import java.io.ObjectInputStream;
013    import java.io.Serializable;
014    import java.text.MessageFormat;
015    import java.util.ArrayList;
016    import java.util.Arrays;
017    import java.util.Collections;
018    import java.util.List;
019    import java.util.Locale;
020    import java.util.TimeZone;
021    import java.util.logging.Logger;
022    import java.util.regex.Matcher;
023    import java.util.regex.Pattern;
024    
025    /**
026     Informative message presented to the end user.
027       
028     <P>This class exists in order to hide the difference between <em>simple</em> and 
029     <em>compound</em> messages. 
030     
031     <P><a name="SimpleMessage"></a><b>Simple Messages</b><br>
032     Simple messages are a single {@link String}, such as <tt>'Item deleted successfully.'</tt>. 
033     They are created using {@link #forSimple(String)}.
034     
035     <P><a name="CompoundMessage"></a><b>Compound Messages</b><br>
036     Compound messages are made up of several parts, and have parameters. They are created 
037     using {@link #forCompound(String, Object...)}. A compound message
038     is usually implemented in Java using {@link java.text.MessageFormat}. <span class="highlight">
039     However, <tt>MessageFormat</tt> is not used by this class, to avoid the following issues </span>:
040    <ul>
041     <li> the dreaded apostrophe problem. In <tt>MessageFormat</tt>, the apostrophe is a special 
042     character, and must be escaped. This is highly unnatural for translators, and has been a 
043     source of continual, bothersome errors. (This is the principal reason for not 
044     using <tt>MessageFormat</tt>.)
045     <li>the <tt>{0}</tt> placeholders start at <tt>0</tt>, not <tt>1</tt>. Again, this is 
046     unnatural for translators.
047     <li>the number of parameters cannot exceed <tt>10</tt>. (Granted, it is not often 
048     that a large number of parameters are needed, but there is no reason why this 
049     restriction should exist.)
050     <li>in general, {@link MessageFormat} is rather complicated in its details.
051    </ul> 
052     
053     <P><a name="CustomFormat"></a><b>Format of Compound Messages</b><br>
054     This class defines an alternative format to that defined by {@link java.text.MessageFormat}. 
055     For example,  
056     <PRE>
057      "At this restaurant, the _1_ meal costs _2_."
058      "On _2_, I am going to Manon's place to see _1_."
059     </PRE>
060     Here, 
061    <ul>
062     <li>the placeholders appear as <tt>_1_</tt>, <tt>_2_</tt>, and so on. 
063     They start at <tt>1</tt>, not <tt>0</tt>, and have no upper limit. There is no escaping 
064     mechanism to allow the placeholder text to appear in the message 'as is'. The <tt>_i_</tt>
065     placeholders stand for an <tt>Object</tt>, and carry no format information.
066     <li>apostrophes can appear anywhere, and do not need to be escaped.
067     <li>the formats applied to the various parameters are taken from {@link Formats}. 
068     If the default formatting applied by {@link Formats} is not desired, then the caller 
069     can always manually format the parameter as a {@link String}. (The {@link Translator} may be used when 
070     a different pattern is needed for different Locales.)
071     <li>the number of parameters passed at runtime must match exactly the number of <tt>_i_</tt> 
072     placeholders
073    </ul>
074     
075     <P><b>Multilingual Applications</b><br>
076     Multilingual applications will need to ensure that messages can be successfully translated when 
077     presented in JSPs. In particular, some care must be exercised to <em>not</em> create 
078     a <em>simple</em> message out of various pieces of data when a <em>compound</em> message  
079     should be used instead. See {@link #getMessage(Locale, TimeZone)}.
080     As well, see the <a href="../ui/translate/package-summary.html">hirondelle.web4j.ui.translate</a> 
081     package for more information, in particular the 
082     {@link hirondelle.web4j.ui.translate.Messages} tag used for rendering <tt>AppResponseMessage</tt>s, 
083     even in single language applications.
084     
085      <P><b>Serialization</b><br>
086      This class implements {@link Serializable} to allow messages stored in session scope to 
087      be transferred over a network, and thus survive a failover operation. 
088      <i>However, this class's implementation of Serializable interface has a minor defect.</i>  
089      This class accepts <tt>Object</tt>s as parameters to messages. These objects almost always represent 
090      data - String, Integer, Id, DateTime, and so on, and all such building block classes are Serializable. 
091      If, however, the caller passes an unusual message parameter object which is not Serializable, then the 
092      serialization of this object (if it occurs), will fail. 
093      
094      <P>The above defect will likely not be fixed since it has large ripple effects, and would seem to cause 
095      more problems than it would solve. In retrospect, this the message parameters passed to 
096      {@link #forCompound(String, Object[])} should likely have been typed as Serializable, not Object. 
097    */
098    public final class AppResponseMessage implements Serializable {
099    
100      /**
101       <a href="#SimpleMessage">Simple message</a> having no parameters.
102       <tt>aSimpleText</tt> must have content.
103      */
104      public static AppResponseMessage forSimple(String aSimpleText){
105        return new AppResponseMessage(aSimpleText, NO_PARAMS);
106      }
107    
108      /**
109       <a href="#CompoundMessage">Compound message</a> having parameters.
110       
111       <P><tt>aPattern</tt> follows the <a href="#CustomFormat">custom format</a> defined by this class.
112       {@link Formats#objectToTextForReport} will be used to format all parameters.
113        
114       @param aPattern must be in the style of the <a href="#CustomFormat">custom format</a>, and 
115       the number of placeholders must match the number of items in <tt>aParams</tt>. 
116       @param aParams must have at least one member; all members must be non-null, but may be empty 
117       {@link String}s.
118      */
119      public static AppResponseMessage forCompound(String aPattern, Object... aParams){
120        if ( aParams.length < 1 ){
121          throw new IllegalArgumentException("Compound messages must have at least one parameter.");
122        }
123        return new AppResponseMessage(aPattern, aParams);
124      }
125      
126      /**
127       Return either the 'simple text' or the <em>formatted</em> pattern with all parameter data rendered, 
128       according to which factory method was called. 
129       
130       <P>The configured {@link Translator} is used to localize 
131       <ul>
132       <li>the text passed to {@link #forSimple(String)} 
133       <li>the pattern passed to {@link #forCompound(String, Object...)}
134       <li>any {@link hirondelle.web4j.request.RequestParameter} parameters passed to {@link #forCompound(String, Object...)}
135       are localized by using {@link Translator} on the return value of {@link RequestParameter#getName()} 
136       (This is intended for displaying localized versions of control names.) 
137       </ul>
138        
139       <P>It is highly recommended that this method be called <em>late</em> in processing, in a JSP.
140       
141       <P>The <tt>Locale</tt> should almost always come from 
142       {@link hirondelle.web4j.BuildImpl#forLocaleSource()}.
143       The <tt>aLocale</tt> parameter is always required, even though there are cases when it 
144       is not actually used to render the result. 
145      */
146      public String getMessage(Locale aLocale, TimeZone aTimeZone){
147        String result = null;
148        Translator translator = BuildImpl.forTranslator();
149        Formats formats = new Formats(aLocale, aTimeZone);
150        if( fParams.isEmpty() ){
151          result = translator.get(fText, aLocale);
152        }
153        else {
154          String localizedPattern = translator.get(fText, aLocale);
155          List<String> formattedParams = new ArrayList<String>();
156          for (Object param : fParams){
157            if ( param instanceof RequestParameter ){
158              RequestParameter reqParam = (RequestParameter)param;
159              String translatedParamName = translator.get(reqParam.getName(), aLocale); 
160              formattedParams.add( translatedParamName ); 
161            }
162            else {
163              //this will escape any special HTML chars in params :
164              formattedParams.add( formats.objectToTextForReport(param).toString() );
165            }
166          }
167          result = populateParamsIntoCustomFormat(localizedPattern, formattedParams);
168        }
169        return result;
170      }
171      
172      /**
173       Return an unmodifiable <tt>List</tt> corresponding to the <tt>aParams</tt> passed to 
174       the constructor.
175       
176       <P>If no parameters are being used, then return an empty list.
177      */
178      public List<Object> getParams(){
179        return Collections.unmodifiableList(fParams);  
180      }
181    
182      /**
183       Return either the 'simple text' or the pattern, according to which factory method 
184       was called. Typically, this method is <em>not</em> used to present text to the user (see {@link #getMessage}). 
185      */
186      @Override public String toString(){
187        return fText;
188      }
189      
190      @Override public boolean equals(Object aThat){
191        Boolean result = ModelUtil.quickEquals(this, aThat);
192        if ( result == null ){
193          AppResponseMessage that = (AppResponseMessage) aThat;
194          result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
195        }
196        return result;    
197      }
198      
199      @Override public int hashCode(){
200        return ModelUtil.hashCodeFor(getSignificantFields());
201      }
202      
203      // PRIVATE 
204      
205      /** Holds either the simple text, or the custom pattern.  */
206      private final String fText;
207      
208      /** List of Objects holds the parameters. Empty List if no parameters used.  */
209      private final List<Object> fParams;
210      
211      private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("_(\\d)+_");
212      private static final Object[] NO_PARAMS = new Object[0];
213      private static final Logger fLogger = Util.getLogger(AppResponseMessage.class);
214      
215      private static final long serialVersionUID = 1000L;
216      
217      private AppResponseMessage(String aText, Object... aParams){
218        fText = aText;
219        fParams = Arrays.asList(aParams);
220        validateState();
221      }
222      
223      private void validateState(){
224        Args.checkForContent(fText);
225        if (fParams != null && fParams.size() > 0){
226          for(Object item : fParams){
227            if ( item == null ){
228              throw new IllegalArgumentException("Parameters to compound messages must be non-null.");
229            }
230          }
231        }
232      }
233    
234      /**
235       @param aFormattedParams contains Strings ready to be placed in to the pattern. The index <tt>i</tt> of the 
236       List matches the <tt>_i_</tt> placeholder. The size of aFormattedParams must match the number of 
237       placeholders.
238      */
239      private String populateParamsIntoCustomFormat(String aPattern, List<String> aFormattedParams){
240        StringBuffer result = new StringBuffer();
241        fLogger.finest("Populating " + Util.quote(aPattern) + " with params " + Util.logOnePerLine(aFormattedParams));
242        Matcher matcher = PLACEHOLDER_PATTERN.matcher(aPattern);
243        int numMatches = 0;
244        while ( matcher.find() ) {
245          ++numMatches;
246          if(numMatches > aFormattedParams.size()){
247            String message = "The number of placeholders exceeds the number of available parameters (" + aFormattedParams.size() + ")";
248            fLogger.severe(message);
249            throw new IllegalArgumentException(message);
250          }
251          matcher.appendReplacement(result, getReplacement(matcher, aFormattedParams));
252        }
253        if(numMatches < aFormattedParams.size()){
254          String message = "The number of placeholders (" + numMatches + ") is less than the number of available parameters (" + aFormattedParams.size() + ")";
255          fLogger.severe(message);
256          throw new IllegalArgumentException(message);
257        }
258        matcher.appendTail(result);
259        return result.toString();    
260      }
261      
262      private String getReplacement(Matcher aMatcher, List<String> aFormattedParams){
263        String result = null;
264        String digit = aMatcher.group(1);
265        int idx = Integer.parseInt(digit);
266        if(idx <= 0){
267          throw new IllegalArgumentException("Placeholder digit should be 1,2,3... but takes value " + idx);
268        }
269        if(idx > aFormattedParams.size()){
270          throw new IllegalArgumentException("Placeholder index for _" + idx + "_ exceeds the number of available parameters (" + aFormattedParams.size() + ")");
271        }
272        result = aFormattedParams.get(idx - 1);
273        return EscapeChars.forReplacementString(result);
274      }
275      
276      private Object[] getSignificantFields(){
277        return new Object[] {fText, fParams};
278      }
279      
280       /**
281        Always treat de-serialization as a full-blown constructor, by validating the final state of the deserialized object.
282       */
283       private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException {
284         aInputStream.defaultReadObject();
285         validateState();
286      }
287    }