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 }