package hirondelle.web4j.request;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.ServletConfig;
import hirondelle.web4j.readconfig.InitParam;
import hirondelle.web4j.request.DateConverter;
import hirondelle.web4j.security.SafeText;
import hirondelle.web4j.util.Consts;
import hirondelle.web4j.util.Regex;
import hirondelle.web4j.util.Util;
import hirondelle.web4j.model.Code;
import hirondelle.web4j.model.DateTime;
import hirondelle.web4j.model.Id;
import hirondelle.web4j.model.Decimal;
import hirondelle.web4j.BuildImpl;
import hirondelle.web4j.TESTAll;
/**
Standard display formats for the application.
<P>The formats used by this class are <em>mostly</em> configured in
<tt>web.xml</tt>, and are read by this class upon startup.
<span class="highlight">See the <a href='http://www.web4j.com/UserGuide.jsp#ConfiguringWebXml'>User Guide</tt>
for more information.</span>
<P>Most formats are localized using the {@link java.util.Locale} passed to this object.
See {@link LocaleSource} for more information.
<P>These formats are intended for implementing standard formats for display of
data both in forms ({@link hirondelle.web4j.ui.tag.Populate}) and in
listings ({@link hirondelle.web4j.database.Report}).
<P>See also {@link DateConverter}, which is also used by this class.
*/
public final class Formats {
/** Called only from {@link RequestParser}, upon startup. */
/*(TESTING from FormPopulator) public */ static void init(ServletConfig aConfig){
fBigDecimalDisplayFormat = fBIG_DECIMAL_DISPLAY_FORMAT.fetch(aConfig).getValue();
fDecimalSeparator = fDECIMAL_SEPARATOR.fetch(aConfig).getValue();
fIntegerFormat = fINTEGER_FORMAT.fetch(aConfig).getValue();
fBooleanTrueText = fBOOLEAN_TRUE_TEXT.fetch(aConfig).getValue();
fBooleanFalseText = fBOOLEAN_FALSE_TEXT.fetch(aConfig).getValue();
fEmptyOrNullText = fEMPTY_OR_NULL_TEXT.fetch(aConfig).getValue();
fDecimalRegex = buildDecimalFormat();
}
/**
Construct with a {@link Locale} and {@link TimeZone} to be applied to non-localized patterns.
@param aLocale almost always comes from {@link LocaleSource}.
@param aTimeZone almost always comes from {@link TimeZoneSource}. A defensive copy is made of
this mutable object.
*/
public Formats(Locale aLocale, TimeZone aTimeZone){
fLocale = aLocale;
fTimeZone = TimeZone.getTimeZone(aTimeZone.getID()); //defensive copy
if(TESTAll.IS_TESTING) {
fDateConverter = new TestingImpl();
}
else {
fDateConverter = BuildImpl.forDateConverter();
}
}
/** Return the {@link Locale} passed to the constructor. */
public Locale getLocale(){
return fLocale;
}
/** Return a TimeZone of the same id as the one passed to the constructor. */
public TimeZone getTimeZone(){
return TimeZone.getTimeZone(fTimeZone.getID());
}
/** Return the format in which {@link BigDecimal}s and {@link Decimal}s are displayed in a form. */
public DecimalFormat getBigDecimalDisplayFormat(){
return getDecimalFormat(fBigDecimalDisplayFormat);
}
/**
Return the regular expression for validating the format of numeric amounts input by the user, having a
possible decimal portion, with any number of decimals.
<P>The returned {@link Pattern} is controlled by a setting in <tt>web.xml</tt>,
for decimal separator(s). It is suitable for both {@link Decimal} and {@link BigDecimal} values.
This item is not affected by a {@link Locale}.
<P>See <tt>web.xml</tt> for more information.
*/
public Pattern getDecimalInputFormat(){
return fDecimalRegex;
}
/** Return the format in which integer amounts are displayed in a report. */
public DecimalFormat getIntegerReportDisplayFormat(){
return getDecimalFormat(fIntegerFormat);
}
/**
Return the text used to render boolean values in a report.
<P>The return value does not depend on {@link Locale}.
*/
public static String getBooleanDisplayText(Boolean aBoolean){
return aBoolean ? fBooleanTrueText : fBooleanFalseText;
}
/**
Return the text used to render empty or <tt>null</tt> values in a report.
<P>The return value does not depend on {@link Locale}. See <tt>web.xml</tt> for more information.
*/
public static String getEmptyOrNullText() {
return fEmptyOrNullText;
}
/**
Translate an object into text, suitable for presentation <em>in an HTML form</em>.
<P>The intent of this method is to return values matching those POSTed during form submission,
not the visible text presented to the user.
<P>The returned text is not escaped in any way.
That is, <em>if special characters need to be escaped, the caller must perform the escaping</em>.
<P>Apply these policies in the following order :
<ul>
<li>if <tt>null</tt>, return an empty <tt>String</tt>
<li>if a {@link DateTime}, apply {@link DateConverter#formatEyeFriendlyDateTime(DateTime, Locale)}
<li>if a {@link Date}, apply {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)}
<li>if a {@link BigDecimal}, display in the form of {@link BigDecimal#toString}, with
one exception : the decimal separator will be as configured in <tt>web.xml</tt>.
(If the setting for the decimal separator allows for <em>both</em> a period and a comma,
then a period is used.)
<li>if a {@link Decimal}, display the amount only, using the same rendering as for <tt>BigDecimal</tt>
<li>if a {@link TimeZone}, return {@link TimeZone#getID()}
<li>if a {@link Code}, return {@link Code#getId()}.toString()
<li>if a {@link Id}, return {@link Id#getRawString()}
<li>if a {@link SafeText}, return {@link SafeText#getRawString()}
<li>otherwise, return <tt>aObject.toString()</tt>
</ul>
<P>If <tt>aObject</tt> is a <tt>Collection</tt>, then the caller must call
this method for every element in the <tt>Collection</tt>.
@param aObject must not be a <tt>Collection</tt>.
*/
public String objectToText(Object aObject) {
String result = null;
if ( aObject == null ){
result = Consts.EMPTY_STRING;
}
else if ( aObject instanceof DateTime ){
DateTime dateTime = (DateTime)aObject;
result = fDateConverter.formatEyeFriendlyDateTime(dateTime, fLocale);
}
else if ( aObject instanceof Date ){
Date date = (Date)aObject;
result = fDateConverter.formatEyeFriendly(date, fLocale, fTimeZone);
}
else if ( aObject instanceof BigDecimal ){
BigDecimal amount = (BigDecimal)aObject;
result = renderBigDecimal(amount);
}
else if ( aObject instanceof Decimal ){
Decimal money = (Decimal)aObject;
result = renderBigDecimal(money.getAmount());
}
else if ( aObject instanceof TimeZone ) {
TimeZone timeZone = (TimeZone)aObject;
result = timeZone.getID();
}
else if ( aObject instanceof Code ) {
Code code = (Code)aObject;
result = code.getId().getRawString();
}
else if ( aObject instanceof Id ) {
Id id = (Id)aObject;
result = id.getRawString();
}
else if ( aObject instanceof SafeText ) {
//The Populate tag will safely escape all such text data.
//To avoid double escaping, the raw form is returned.
SafeText safeText = (SafeText)aObject;
result = safeText.getRawString();
}
else {
result = aObject.toString();
}
return result;
}
/**
Translate an object into text suitable for direct presentation in a JSP.
<P>In general, a report can be rendered in various ways: HTML, XML, plain text.
Each of these styles has different needs for escaping special characters.
This method returns a {@link SafeText}, which can escape characters in
various ways.
<P>This method applies the following policies to get the <em>unescaped</em> text :
<P>
<table border=1 cellpadding=3 cellspacing=0>
<tr><th>Type</th> <th>Action</th></tr>
<tr>
<td><tt>SafeText</tt></td>
<td>use {@link SafeText#getRawString()}</td>
</tr>
<tr>
<td><tt>Id</tt></td>
<td>use {@link Id#getRawString()}</td>
</tr>
<tr>
<td><tt>Code</tt></td>
<td>use {@link Code#getText()}.getRawString()</td>
</tr>
<tr>
<td><tt>hirondelle.web4.model.DateTime</tt></td>
<td>apply {@link DateConverter#formatEyeFriendlyDateTime(DateTime, Locale)} </td>
</tr>
<tr>
<td><tt>java.util.Date</tt></td>
<td>apply {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)} </td>
</tr>
<tr>
<td><tt>BigDecimal</tt></td>
<td>use {@link #getBigDecimalDisplayFormat} </td>
</tr>
<tr>
<td><tt>Decimal</tt></td>
<td>use {@link #getBigDecimalDisplayFormat} on <tt>decimal.getAmount()</tt></td>
</tr>
<tr>
<td><tt>Boolean</tt></td>
<td>use {@link #getBooleanDisplayText} </td>
</tr>
<tr>
<td><tt>Integer</tt></td>
<td>use {@link #getIntegerReportDisplayFormat} </td>
</tr>
<tr>
<td><tt>Long</tt></td>
<td>use {@link #getIntegerReportDisplayFormat} </td>
</tr>
<tr>
<td><tt>Locale</tt></td>
<td>use {@link Locale#getDisplayName(java.util.Locale)} </td>
</tr>
<tr>
<td><tt>TimeZone</tt></td>
<td>use {@link TimeZone#getDisplayName(boolean, int, java.util.Locale)} (with no daylight savings hour, and in the <tt>SHORT</tt> style </td>
</tr>
<tr>
<td>..other...</td>
<td>
use <tt>toString</tt>, and pass result to constructor of {@link SafeText}.
</td>
</tr>
</table>
<P>In addition, the value returned by {@link #getEmptyOrNullText} is used if :
<ul>
<li><tt>aObject</tt> is itself <tt>null</tt>
<li>the result of the above policies returns text which has no content
</ul>
*/
public SafeText objectToTextForReport(Object aObject) {
String result = null;
if ( aObject == null ){
result = null;
}
else if (aObject instanceof SafeText){
//it is odd to extract an identical object like this,
//but it safely avoids double escaping at the end of this method
SafeText text = (SafeText) aObject;
result = text.getRawString();
}
else if (aObject instanceof Id){
Id id = (Id) aObject;
result = id.getRawString();
}
else if (aObject instanceof Code){
Code code = (Code) aObject;
result = code.getText().getRawString();
}
else if (aObject instanceof String) {
result = aObject.toString();
}
else if ( aObject instanceof DateTime ){
DateTime dateTime = (DateTime)aObject;
result = fDateConverter.formatEyeFriendlyDateTime(dateTime, fLocale);
}
else if ( aObject instanceof Date ){
Date date = (Date)aObject;
result = fDateConverter.formatEyeFriendly(date, fLocale, fTimeZone);
}
else if ( aObject instanceof BigDecimal ){
BigDecimal amount = (BigDecimal)aObject;
result = getBigDecimalDisplayFormat().format(amount.doubleValue());
}
else if ( aObject instanceof Decimal ){
Decimal money = (Decimal)aObject;
result = getBigDecimalDisplayFormat().format(money.getAmount().doubleValue());
}
else if ( aObject instanceof Boolean ){
Boolean value = (Boolean)aObject;
result = getBooleanDisplayText(value);
}
else if ( aObject instanceof Integer ) {
Integer value = (Integer)aObject;
result = getIntegerReportDisplayFormat().format(value);
}
else if ( aObject instanceof Long ) {
Long value = (Long)aObject;
result = getIntegerReportDisplayFormat().format(value.longValue());
}
else if ( aObject instanceof Locale ) {
Locale locale = (Locale)aObject;
result = locale.getDisplayName(fLocale);
}
else if ( aObject instanceof TimeZone ) {
TimeZone timeZone = (TimeZone)aObject;
result = timeZone.getDisplayName(false, TimeZone.SHORT, fLocale);
}
else {
result = aObject.toString();
}
//ensure that all empty results have configured content
if ( ! Util.textHasContent(result) ) {
result = fEmptyOrNullText;
}
return new SafeText(result);
}
// PRIVATE //
private final Locale fLocale;
private final TimeZone fTimeZone;
private final DateConverter fDateConverter;
private static Pattern fDecimalRegex;
private static final InitParam fBIG_DECIMAL_DISPLAY_FORMAT = new InitParam("BigDecimalDisplayFormat", "#,##0.00");
private static String fBigDecimalDisplayFormat;
private static final InitParam fDECIMAL_SEPARATOR = new InitParam("DecimalSeparator", "PERIOD");
private static String fDecimalSeparator;
private static final InitParam fBOOLEAN_TRUE_TEXT = new InitParam("BooleanTrueDisplayFormat", "<input type='checkbox' name='true' value='true' checked readonly notab>");
private static String fBooleanTrueText;
private static final InitParam fBOOLEAN_FALSE_TEXT = new InitParam("BooleanFalseDisplayFormat", "<input type='checkbox' name='false' value='false' readonly notab>");
private static String fBooleanFalseText;
private static final InitParam fEMPTY_OR_NULL_TEXT = new InitParam("EmptyOrNullDisplayFormat", "-");
private static String fEmptyOrNullText;
private static final InitParam fINTEGER_FORMAT = new InitParam("IntegerDisplayFormat", "#,###");
private static String fIntegerFormat;
private static final String COMMA = "COMMA";
private static final String PERIOD = "PERIOD";
private static final String PERIOD_OR_COMMA = "PERIOD,COMMA";
private static final Logger fLogger = Util.getLogger(Formats.class);
private DecimalFormat getDecimalFormat(String aFormat){
DecimalFormat result = null;
NumberFormat format = NumberFormat.getNumberInstance(fLocale);
if (format instanceof DecimalFormat){
result = (DecimalFormat)format;
}
else {
throw new AssertionError();
}
result.applyPattern(aFormat);
return result;
}
private static void vomit(String aMessage){
fLogger.severe(aMessage);
throw new IllegalArgumentException(aMessage);
}
/**
Return the pattern applicable to numeric input of a number with a possible decimal portion.
*/
private static Pattern buildDecimalFormat(){
String pattern = "";
String sign = "(?:-|\\+)?";
String digits = "[0-9]+";
String decimalSign = getDecimalSignPattern();
String places = "[0-9]+";
// pattern = sign?(digits|digits.places|.places)
pattern = sign + "(" + digits + Regex.OR + digits + decimalSign + places + Regex.OR + decimalSign + places + ")";
return Pattern.compile(pattern);
}
private static String getDecimalSignPattern(){
String result = null;
if( PERIOD.equalsIgnoreCase(fDecimalSeparator) ) {
result = "(?:\\.)";
}
else if ( COMMA.equalsIgnoreCase(fDecimalSeparator) ){
result = "(?:,)";
}
else if ( PERIOD_OR_COMMA.equalsIgnoreCase(fDecimalSeparator)){
result = "(?:\\.|,)";
}
else {
vomit(
"In web.xml, the setting for DecimalSeparator is not in the expected format. " +
"See web.xml for more information."
);
}
return result;
}
private String replacePeriodWithComma(String aValue){
return aValue.replace(".", ",");
}
/** For TESTING only. */
private static final class TestingImpl implements DateConverter {
//Dates:
public String formatEyeFriendly(Date aDate, Locale aLocale, TimeZone aTimeZone) {
SimpleDateFormat format = new SimpleDateFormat("MM/dd/yyyy");
//format.setTimeZone(aTimeZone);
return format.format(aDate);
}
public Date parseHandFriendly(String aInputValue, Locale aLocale, TimeZone aTimeZone) {
return parseDate(aInputValue, HAND_FRIENDLY_REGEX);
}
public Date parseEyeFriendly(String aInputValue, Locale aLocale, TimeZone aTimeZone) {
return parseDate(aInputValue, EYE_FRIENDLY_REGEX);
}
//DateTimes:
public String formatEyeFriendlyDateTime(DateTime aDateTime, Locale aLocale) {
return aDateTime.format("MM/DD/YYYY", aLocale);
}
public DateTime parseEyeFriendlyDateTime(String aInputValue, Locale aLocale) {
return parseDateTime(aInputValue, EYE_FRIENDLY_REGEX);
}
public DateTime parseHandFriendlyDateTime(String aInputValue, Locale aLocale) {
return parseDateTime(aInputValue, HAND_FRIENDLY_REGEX);
}
private static final Pattern HAND_FRIENDLY_REGEX =
Pattern.compile(Regex.MONTH + Regex.DAY_OF_MONTH + "(\\d\\d\\d\\d)");
;
private static final Pattern EYE_FRIENDLY_REGEX =
Pattern.compile(Regex.MONTH + "/" + Regex.DAY_OF_MONTH + "/" + "(\\d\\d\\d\\d)")
;
private Date parseDate(String aInputValue, Pattern aRegex){
Date result = null;
Matcher matcher = aRegex.matcher(aInputValue);
if( matcher.matches() ) {
Integer month = new Integer(matcher.group(Regex.FIRST_GROUP));
Integer day = new Integer(matcher.group(Regex.SECOND_GROUP));
Integer year = new Integer( matcher.group(Regex.THIRD_GROUP) );
Calendar cal = new GregorianCalendar(year.intValue(), month.intValue() - 1, day.intValue(), 0,0,0);
result = cal.getTime();
}
return result;
}
private DateTime parseDateTime(String aInputValue, Pattern aRegex){
DateTime result = null;
Matcher matcher = aRegex.matcher(aInputValue);
if( matcher.matches() ) {
Integer month = new Integer(matcher.group(Regex.FIRST_GROUP));
Integer day = new Integer(matcher.group(Regex.SECOND_GROUP));
Integer year = new Integer( matcher.group(Regex.THIRD_GROUP) );
result = DateTime.forDateOnly(year, month, day);
}
return result;
}
}
private String renderBigDecimal(BigDecimal aBigDecimal){
String result = aBigDecimal.toPlainString();
if( COMMA.equalsIgnoreCase(fDecimalSeparator) ){
result = replacePeriodWithComma(result);
}
return result;
}
/** Informal test harness. */
private static void main(String... args){
init(null);
Formats formats = new Formats(Locale.CANADA, TimeZone.getTimeZone("Canada/Atlantic"));
System.out.println("en_fr: " + formats.objectToTextForReport(new Locale("en_fr")));
System.out.println("Canada/Pacific: " + formats.objectToTextForReport(TimeZone.getTimeZone("Canada/Pacific")));
}
}