001    package hirondelle.web4j.request;
002    
003    import hirondelle.web4j.BuildImpl;
004    import hirondelle.web4j.model.Code;
005    import hirondelle.web4j.model.DateTime;
006    import hirondelle.web4j.model.Decimal;
007    import hirondelle.web4j.model.Id;
008    import hirondelle.web4j.readconfig.InitParam;
009    import hirondelle.web4j.security.SafeText;
010    import hirondelle.web4j.util.Consts;
011    import hirondelle.web4j.util.Regex;
012    import hirondelle.web4j.util.Util;
013    
014    import java.math.BigDecimal;
015    import java.text.DecimalFormat;
016    import java.text.NumberFormat;
017    import java.util.Date;
018    import java.util.Locale;
019    import java.util.TimeZone;
020    import java.util.logging.Logger;
021    import java.util.regex.Pattern;
022    
023    import javax.servlet.ServletConfig;
024    
025    /**
026     Standard display formats for the application.
027     
028     <P>The formats used by this class are <em>mostly</em> configured in 
029     <tt>web.xml</tt>, and are read by this class upon startup. 
030     <span class="highlight">See the <a href='http://www.web4j.com/UserGuide.jsp#ConfiguringWebXml'>User Guide</tt> 
031     for more information.</span>
032     
033     <P>Most formats are localized using the {@link java.util.Locale} passed to this object. 
034     See {@link LocaleSource} for more information.  
035    
036     <P>These formats are intended for implementing standard formats for display of 
037     data both in forms ({@link hirondelle.web4j.ui.tag.Populate}) and in 
038     listings ({@link hirondelle.web4j.database.Report}).
039     
040     <P>See also {@link DateConverter}, which is also used by this class.
041    */
042    public final class Formats {
043      
044      /** Called only from {@link RequestParser}, upon startup. */
045      static void init(ServletConfig aConfig){
046        fBigDecimalDisplayFormat = fBIG_DECIMAL_DISPLAY_FORMAT.fetch(aConfig).getValue();
047        fDecimalSeparator = fDECIMAL_SEPARATOR.fetch(aConfig).getValue();
048        fIntegerFormat = fINTEGER_FORMAT.fetch(aConfig).getValue();
049        fBooleanTrueText = fBOOLEAN_TRUE_TEXT.fetch(aConfig).getValue();
050        fBooleanFalseText = fBOOLEAN_FALSE_TEXT.fetch(aConfig).getValue();
051        fEmptyOrNullText = fEMPTY_OR_NULL_TEXT.fetch(aConfig).getValue();
052        fDecimalRegex = buildDecimalFormat();
053      }
054    
055      /**
056       Construct with a {@link Locale} and {@link TimeZone} to be applied to non-localized patterns. 
057       
058       @param aLocale almost always comes from {@link LocaleSource}.
059       @param aTimeZone almost always comes from {@link TimeZoneSource}. A defensive copy is made of 
060       this mutable object.
061      */
062      public Formats(Locale aLocale, TimeZone aTimeZone){
063        fLocale = aLocale;
064        fTimeZone = TimeZone.getTimeZone(aTimeZone.getID()); //defensive copy
065        fDateConverter = BuildImpl.forDateConverter();
066    //    if(TESTAll.IS_TESTING) {
067    //      fDateConverter = new TestingImpl();
068    //    }
069    //    else {
070    //      fDateConverter = BuildImpl.forDateConverter();
071    //    }
072      }
073      
074      /** Return the {@link Locale} passed to the constructor.  */
075      public Locale getLocale(){
076        return fLocale;
077      }
078      
079      /** Return a TimeZone of the same id as the one passed to the constructor.  */
080      public TimeZone getTimeZone(){
081        return TimeZone.getTimeZone(fTimeZone.getID());
082      }
083      
084      /** Return the format in which {@link BigDecimal}s and {@link Decimal}s are displayed in a form.  */
085      public DecimalFormat getBigDecimalDisplayFormat(){
086        return getDecimalFormat(fBigDecimalDisplayFormat);
087      }
088      
089      /**
090       Return the regular expression for validating the format of numeric amounts input by the user, having a 
091       possible decimal portion, with any number of decimals.
092        
093       <P>The returned {@link Pattern} is controlled by a setting in <tt>web.xml</tt>, 
094       for decimal separator(s). It is suitable for both {@link Decimal} and {@link BigDecimal} values.
095       This item is not affected by a {@link Locale}. 
096       
097       <P>See <tt>web.xml</tt> for more information. 
098      */
099      public Pattern getDecimalInputFormat(){
100        return fDecimalRegex;
101      }
102      
103      /** Return the format in which integer amounts are displayed in a report.  */
104      public DecimalFormat getIntegerReportDisplayFormat(){
105        return getDecimalFormat(fIntegerFormat);
106      }
107      
108      /**
109       Return the text used to render boolean values in a report.
110       
111       <P>The return value does not depend on {@link Locale}.
112      */
113      public static String getBooleanDisplayText(Boolean aBoolean){
114        return aBoolean ? fBooleanTrueText : fBooleanFalseText;
115      }
116    
117      /**
118       Return the text used to render empty or <tt>null</tt> values in a report.
119       
120       <P>The return value does not depend on {@link Locale}. See <tt>web.xml</tt> for more information.
121      */
122      public static String getEmptyOrNullText() {
123        return fEmptyOrNullText;
124      }
125      
126      /**
127       Translate an object into text, suitable for presentation <em>in an HTML form</em>.
128       
129       <P>The intent of this method is to return values matching those POSTed during form submission, 
130       not the visible text presented to the user. 
131       
132       <P>The returned text is not escaped in any way.
133       That is, <em>if special characters need to be escaped, the caller must perform the escaping</em>.
134       
135       <P>Apply these policies in the following order :
136      <ul>
137       <li>if <tt>null</tt>, return an empty <tt>String</tt>
138       <li>if a {@link DateTime}, apply {@link DateConverter#formatEyeFriendlyDateTime(DateTime, Locale)}
139       <li>if a {@link Date}, apply {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)}
140       <li>if a {@link BigDecimal}, display in the form of {@link BigDecimal#toString}, with 
141       one exception : the decimal separator will be as configured in <tt>web.xml</tt>. 
142       (If the setting for the decimal separator allows for <em>both</em> a period and a comma, 
143       then a period is used.)
144       <li>if a {@link Decimal}, display the amount only, using the same rendering as for <tt>BigDecimal</tt> 
145       <li>if a {@link TimeZone}, return {@link TimeZone#getID()}
146       <li>if a {@link Code}, return {@link Code#getId()}.toString()
147       <li>if a {@link Id}, return {@link Id#getRawString()}
148       <li>if a {@link SafeText}, return {@link SafeText#getRawString()}
149       <li>otherwise, return <tt>aObject.toString()</tt>
150      </ul>
151       
152       <P>If <tt>aObject</tt> is a <tt>Collection</tt>, then the caller must call 
153       this method for every element in the <tt>Collection</tt>.
154      
155       @param aObject must not be a <tt>Collection</tt>.
156      */
157      public String objectToText(Object aObject) {
158        String result = null;
159        if ( aObject == null ){
160          result = Consts.EMPTY_STRING;
161        }
162        else if ( aObject instanceof DateTime ){
163          DateTime dateTime = (DateTime)aObject;
164          result = fDateConverter.formatEyeFriendlyDateTime(dateTime, fLocale);
165        }
166        else if ( aObject instanceof Date ){
167          Date date = (Date)aObject;
168          result = fDateConverter.formatEyeFriendly(date, fLocale, fTimeZone);
169        }
170        else if ( aObject instanceof BigDecimal ){
171          BigDecimal amount = (BigDecimal)aObject;
172          result = renderBigDecimal(amount);
173        }
174        else if ( aObject instanceof Decimal ){
175          Decimal money = (Decimal)aObject;
176          result = renderBigDecimal(money.getAmount());
177        }
178        else if ( aObject instanceof TimeZone ) {
179          TimeZone timeZone = (TimeZone)aObject;
180          result = timeZone.getID();
181        }
182        else if ( aObject instanceof Code ) {
183          Code code = (Code)aObject;
184          result = code.getId().getRawString();
185        }
186        else if ( aObject instanceof Id ) {
187          Id id = (Id)aObject;
188          result = id.getRawString();
189        }
190        else if ( aObject instanceof SafeText ) {
191          //The Populate tag will safely escape all such text data.
192          //To avoid double escaping, the raw form is returned.
193          SafeText safeText = (SafeText)aObject;
194          result = safeText.getRawString();
195        }
196        else {
197          result = aObject.toString();
198        }
199        return result;
200      }
201      
202      /**
203       Translate an object into text suitable for direct presentation in a JSP.
204       
205       <P>In general, a report can be rendered in various ways: HTML, XML, plain text. 
206       Each of these styles has different needs for escaping special characters. 
207       This method returns a {@link SafeText}, which can escape characters in 
208       various ways. 
209       
210       <P>This method applies the following policies to get the <em>unescaped</em> text :
211       <P>
212       <table border=1 cellpadding=3 cellspacing=0>
213        <tr><th>Type</th> <th>Action</th></tr>
214        <tr>   
215         <td><tt>SafeText</tt></td> 
216         <td>use {@link SafeText#getRawString()}</td>
217        </tr>
218        <tr>   
219         <td><tt>Id</tt></td> 
220         <td>use {@link Id#getRawString()}</td>
221        </tr>
222        <tr>   
223         <td><tt>Code</tt></td> 
224         <td>use {@link Code#getText()}.getRawString()</td>
225        </tr>
226        <tr>
227         <td><tt>hirondelle.web4.model.DateTime</tt></td> 
228         <td>apply {@link DateConverter#formatEyeFriendlyDateTime(DateTime, Locale)} </td>
229        </tr>
230        <tr>
231         <td><tt>java.util.Date</tt></td> 
232         <td>apply {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)} </td>
233        </tr>
234        <tr>
235         <td><tt>BigDecimal</tt></td> 
236         <td>use {@link #getBigDecimalDisplayFormat} </td>
237        </tr>
238        <tr>
239         <td><tt>Decimal</tt></td> 
240         <td>use {@link #getBigDecimalDisplayFormat} on <tt>decimal.getAmount()</tt></td>
241        </tr>
242        <tr>
243         <td><tt>Boolean</tt></td> 
244         <td>use {@link #getBooleanDisplayText} </td>
245        </tr>
246        <tr>
247         <td><tt>Integer</tt></td> 
248         <td>use {@link #getIntegerReportDisplayFormat} </td>
249        </tr>
250        <tr>
251         <td><tt>Long</tt></td> 
252         <td>use {@link #getIntegerReportDisplayFormat} </td>
253        </tr>
254        <tr>   
255         <td><tt>Locale</tt></td> 
256         <td>use {@link Locale#getDisplayName(java.util.Locale)} </td>
257        </tr>
258        <tr>   
259         <td><tt>TimeZone</tt></td> 
260         <td>use {@link TimeZone#getDisplayName(boolean, int, java.util.Locale)} (with no daylight savings hour, and in the <tt>SHORT</tt> style </td>
261        </tr>
262        <tr>
263         <td>..other...</td> 
264         <td>
265           use <tt>toString</tt>, and pass result to constructor of {@link SafeText}. 
266         </td>
267        </tr>
268       </table>
269      
270       <P>In addition, the value returned by {@link #getEmptyOrNullText} is used if :
271       <ul>
272       <li><tt>aObject</tt> is itself <tt>null</tt>
273       <li>the result of the above policies returns text which has no content
274      </ul>
275      */
276      public SafeText objectToTextForReport(Object aObject) {
277        String result = null;
278        if ( aObject == null ){
279          result = null;
280        }
281        else if (aObject instanceof SafeText){
282          //it is odd to extract an identical object like this, 
283          //but it safely avoids double escaping at the end of this method
284          SafeText text = (SafeText) aObject;
285          result = text.getRawString();
286        }
287        else if (aObject instanceof Id){
288          Id id = (Id) aObject;
289          result = id.getRawString();
290        }
291        else if (aObject instanceof Code){
292          Code code = (Code) aObject;
293          result = code.getText().getRawString();
294        }
295        else if (aObject instanceof String) {
296          result = aObject.toString();
297        }
298        else if ( aObject instanceof DateTime ){
299          DateTime dateTime = (DateTime)aObject;
300          result = fDateConverter.formatEyeFriendlyDateTime(dateTime, fLocale);
301        }
302        else if ( aObject instanceof Date ){
303          Date date = (Date)aObject;
304          result = fDateConverter.formatEyeFriendly(date, fLocale, fTimeZone);
305        }
306        else if ( aObject instanceof BigDecimal ){
307          BigDecimal amount = (BigDecimal)aObject;
308          result = getBigDecimalDisplayFormat().format(amount.doubleValue());
309        }
310        else if ( aObject instanceof Decimal ){
311          Decimal money = (Decimal)aObject;
312          result = getBigDecimalDisplayFormat().format(money.getAmount().doubleValue());
313        }
314        else if ( aObject instanceof Boolean ){
315          Boolean value = (Boolean)aObject;
316          result = getBooleanDisplayText(value);
317        }
318        else if ( aObject instanceof Integer ) {
319          Integer value = (Integer)aObject;
320          result = getIntegerReportDisplayFormat().format(value);
321        }
322        else if ( aObject instanceof Long ) {
323          Long value = (Long)aObject;
324          result = getIntegerReportDisplayFormat().format(value.longValue());
325        }
326        else if ( aObject instanceof Locale ) {
327          Locale locale = (Locale)aObject;
328          result = locale.getDisplayName(fLocale);
329        }
330        else if ( aObject instanceof TimeZone ) {
331          TimeZone timeZone = (TimeZone)aObject;
332          result = timeZone.getDisplayName(false, TimeZone.SHORT, fLocale);
333        }
334        else {
335          result = aObject.toString();
336        }
337        //ensure that all empty results have configured content
338        if ( ! Util.textHasContent(result) ) {
339          result = fEmptyOrNullText;
340        }
341        return new SafeText(result);
342      }
343      
344      // PRIVATE //
345      private final Locale fLocale;
346      private final TimeZone fTimeZone;
347      private final DateConverter fDateConverter;
348      private static Pattern fDecimalRegex;
349     
350      private static final InitParam fBIG_DECIMAL_DISPLAY_FORMAT = new InitParam("BigDecimalDisplayFormat", "#,##0.00");
351      private static String fBigDecimalDisplayFormat;
352    
353      private static final InitParam fDECIMAL_SEPARATOR = new InitParam("DecimalSeparator", "PERIOD");
354      private static String fDecimalSeparator;
355      
356      private static final InitParam fBOOLEAN_TRUE_TEXT = new InitParam("BooleanTrueDisplayFormat", "<input type='checkbox' name='true' value='true' checked readonly notab>");
357      private static String fBooleanTrueText;
358      
359      private static final InitParam fBOOLEAN_FALSE_TEXT = new InitParam("BooleanFalseDisplayFormat", "<input type='checkbox' name='false' value='false' readonly notab>");
360      private static String fBooleanFalseText;
361      
362      private static final InitParam fEMPTY_OR_NULL_TEXT = new InitParam("EmptyOrNullDisplayFormat", "-");
363      private static String fEmptyOrNullText;
364    
365      private static final InitParam fINTEGER_FORMAT = new InitParam("IntegerDisplayFormat", "#,###");
366      private static String fIntegerFormat;
367      
368      private static final String COMMA = "COMMA";
369      private static final String PERIOD = "PERIOD";
370      private static final String PERIOD_OR_COMMA = "PERIOD,COMMA";
371      
372      private static final Logger fLogger = Util.getLogger(Formats.class);
373      
374      private DecimalFormat getDecimalFormat(String aFormat){
375        DecimalFormat result = null;
376        NumberFormat format = NumberFormat.getNumberInstance(fLocale);
377        if (format instanceof DecimalFormat){
378          result = (DecimalFormat)format;
379        }
380        else {
381          throw new AssertionError();
382        }
383        result.applyPattern(aFormat);
384        return result;
385      }
386      
387      private static void vomit(String aMessage){
388        fLogger.severe(aMessage);
389        throw new IllegalArgumentException(aMessage);
390      }
391      
392      /**
393       Return the pattern applicable to numeric input of a number with a possible decimal portion. 
394      */
395      private static Pattern buildDecimalFormat(){
396        String pattern = "";
397        String sign = "(?:-|\\+)?";
398        String digits = "[0-9]+";
399        String decimalSign = getDecimalSignPattern();
400        String places  = "[0-9]+";
401        
402        // pattern = sign?(digits|digits.places|.places) 
403        pattern = sign + "(" + digits + Regex.OR + digits + decimalSign + places + Regex.OR + decimalSign + places + ")";
404        return Pattern.compile(pattern);
405      }
406      
407      private static String getDecimalSignPattern(){
408        String result = null;
409        if( PERIOD.equalsIgnoreCase(fDecimalSeparator) ) {
410          result = "(?:\\.)";
411        }
412        else if ( COMMA.equalsIgnoreCase(fDecimalSeparator) ){ 
413          result = "(?:,)";
414        }
415        else if ( PERIOD_OR_COMMA.equalsIgnoreCase(fDecimalSeparator)){
416          result = "(?:\\.|,)";
417        }
418        else {
419          vomit(
420            "In web.xml, the setting for DecimalSeparator is not in the expected format. " + 
421            "See web.xml for more information."
422          );
423        }
424        return result;
425      }
426      
427      private String replacePeriodWithComma(String aValue){
428        return aValue.replace(".", ",");
429      }
430      
431      /** For TESTING only.  */  
432      
433      private String renderBigDecimal(BigDecimal aBigDecimal){
434        String result = aBigDecimal.toPlainString();
435        if( COMMA.equalsIgnoreCase(fDecimalSeparator) ){
436          result = replacePeriodWithComma(result);
437        }
438        return result;
439      }
440      
441      /** Informal test harness.   */
442      private static void main(String... args){
443        init(null);
444        Formats formats = new Formats(Locale.CANADA, TimeZone.getTimeZone("Canada/Atlantic"));
445        System.out.println("en_fr: " + formats.objectToTextForReport(new Locale("en_fr")));
446        System.out.println("Canada/Pacific: " + formats.objectToTextForReport(TimeZone.getTimeZone("Canada/Pacific")));
447      }
448    }