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 }