/* Copyright 2005-2006 Tim Fennell * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package net.sourceforge.stripes.validation; import net.sourceforge.stripes.controller.StripesFilter; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Collection; import java.util.Date; import java.util.Locale; import java.util.MissingResourceException; import java.util.regex.Pattern; /** * <p>A TypeConverter that aggressively attempts to convert a String to a java.util.Date object. * Under the covers it uses DateFormat instances to do the heavy lifting, but since * SimpleDateFormat is quite picky about its input a couple of measures are taken to improve our * chances of parsing a Date.</p> * * <p>First the String is pre-processed to replace commas, slashes, hyphens and periods with spaces * and to collapse multiple white spaces into a single space. Then, to ensure that input like * "Jan 1" and "3/19" are parseable a check is performed to see if there are only two segments * in the input string (e.g. "Jan" and "1" but no "2007"). If that is the case then the current * four digit year is appended to improve chances or parsing because a two segment date is not * parsable by a DateFormat that expects a year.</p> * * <p>Then an array of DateFormat instances are used in turn to attempt to parse the input. If any * DateFormat succeeds and returns a Date, that Date will be returned as the result of the * conversion. If all DateFormats fail, a validation error will be produced.</p> * * <p>The set of formats is obtained from getFormatStrings(). The default set of formats used is * constructed by taking the default SHORT, MEDIUM and LONG formats for the input locale (and * replacing all non-space separator characters with spaces), and adding formats to obtain the * following patterns:</p> * * <ul> * <li>SHORT (e.g. 'M d yy' for Locale.US)</li> * <li>MEDIUM (e.g. 'MMM d yyyy' for Locale.US)</li> * <li>LONG (e.g. 'MMMM d yyyy' for Locale.US)</li> * <li>d MMM yy (note that for parsing MMM and MMMM are interchangeable)</li> * <li>yyyy M d (note that for parsing M and MM are interchangeable)</li> * <li>yyyy MMM d</li> * <li>EEE MMM dd HH:mm:ss zzz yyyy (the format created by Date.toString())</li> * </ul> * </p> * * <p>This default set of formats can be changed by providing a different set of format strings in * the Stripes resource bundle, or by subclassing and overriding getFormatStrings(). In all cases * patterns should be specified using single spaces as separators instead of slashes, dashes * or other characters.</p> * * <p>The regular expression pattern used in the pre-process method can also be changed in the * Stripes resource bundle, or by subclassing and overriding the getPreProcessPattern() method.</p> * * <p>The keys used in the resource bundle to specify the format strings and the pre-process * pattern are: * <ul> * <li>stripes.dateTypeConverter.formatStrings</li> * <li>stripes.dateTypeConverter.preProcessPattern</li> * </ul> * </p> * * <p>DateTypeConverter can also be overridden in order to change its behaviour. Subclasses can * override the preProcessInput() method to change the pre-processing behavior if desired. Similarly, * subclasses can override getDateFormats() to change how the DateFormat objects get constructed. * </p> */ public class DateTypeConverter implements TypeConverter<Date> { private Locale locale; private DateFormat[] formats; /** * Used by Stripes to set the input locale. Once the locale is set a number of DateFormat * instances are created ready to convert any input. */ public void setLocale(Locale locale) { this.locale = locale; this.formats = getDateFormats(); } /** * @return the current input locale. */ public Locale getLocale() { return locale; } /** * <p>A pattern used to pre-process Strings before the parsing attempt is made. Since * SimpleDateFormat strictly enforces that the separator characters in the input are the same * as those in the pattern, this regular expression is used to remove commas, slashes, hyphens * and periods from the input String (replacing them with spaces) and to collapse multiple * white-space characters into a single space.</p> * * <p>This pattern can be changed by providing a different value under the * <code>'stripes.dateTypeConverter.preProcessPattern'</code> key in the resource * bundle. The default value is <code>(?<!GMT)[\\s,-/\\.]+</code>.</p> */ public static final Pattern PRE_PROCESS_PATTERN = Pattern.compile("(?<!GMT)[\\s,/\\.-]+"); /** The default set of date patterns used to parse dates with SimpleDateFormat. */ public static final String[] formatStrings = new String[] { "d MMM yy", "yyyy M d", "yyyy MMM d", "EEE MMM dd HH:mm:ss zzz yyyy" }; /** The key used to look up the localized format strings from the resource bundle. */ public static final String KEY_FORMAT_STRINGS = "stripes.dateTypeConverter.formatStrings"; /** The key used to look up the pre-process pattern from the resource bundle. */ public static final String KEY_PRE_PROCESS_PATTERN = "stripes.dateTypeConverter.preProcessPattern"; /** * Returns an array of format strings that will be used, in order, to try and parse the date. * This method can be overridden to make DateTypeConverter use a different set of format * Strings. Given that pre-processing converts most common separator characters into spaces, * patterns should be expressed with spaces as separators, not slashes, hyphens etc. */ protected String[] getFormatStrings() { try { return getResourceString(KEY_FORMAT_STRINGS).split(", *"); } catch (MissingResourceException mre) { // First get the locale specific date format patterns int[] dateFormats = { DateFormat.SHORT, DateFormat.MEDIUM, DateFormat.LONG }; String[] formatStrings = new String[dateFormats.length + DateTypeConverter.formatStrings.length]; for (int i=0; i<dateFormats.length; i++) { SimpleDateFormat dateFormat = (SimpleDateFormat) DateFormat.getDateInstance(dateFormats[i], locale); formatStrings[i] = preProcessInput(dateFormat.toPattern()); } // Then copy the default format strings over too System.arraycopy(DateTypeConverter.formatStrings, 0, formatStrings, dateFormats.length, DateTypeConverter.formatStrings.length); return formatStrings; } } /** * Returns an array of DateFormat objects that will be used in sequence to try and parse the * date String. This method will be called once when the DateTypeConverter instance is * initialized. It first calls getFormatStrings() to obtain the format strings that are used to * construct SimpleDateFormat instances. */ protected DateFormat[] getDateFormats() { String[] formatStrings = getFormatStrings(); SimpleDateFormat[] dateFormats = new SimpleDateFormat[formatStrings.length]; for (int i=0; i<formatStrings.length; i++) { dateFormats[i] = new SimpleDateFormat(formatStrings[i], locale); dateFormats[i].setLenient(false); } return dateFormats; } /** * Attempts to convert a String to a Date object. Pre-processes the input by invoking the * method preProcessInput(), then uses an ordered list of DateFormat objects (supplied by * getDateFormats()) to try and parse the String into a Date. */ public Date convert(String input, Class<? extends Date> targetType, Collection<ValidationError> errors) { // Step 1: pre-process the input to make it more palatable String parseable = preProcessInput(input); // Step 2: try really hard to parse the input Date date = null; for (DateFormat format : this.formats) { try { date = format.parse(parseable); break; } catch (ParseException pe) { /* Do nothing, we'll get lots of these. */ } } // Step 3: If we successfully parsed, return a date, otherwise send back an error if (date != null) { return date; } else { errors.add( new ScopedLocalizableError("converter.date", "invalidDate") ); return null; } } /** * Returns the regular expression pattern used in the pre-process method. Looks for a pattern in * the resource bundle under the key 'stripes.dateTypeConverter.preProcessPattern'. If no value * is found, the pattern <code>(?<!GMT)[\\s,-/\\.]+</code> is used by default. The pattern is * used by preProcessInput() to replace all matches by single spaces. */ protected Pattern getPreProcessPattern() { try { return Pattern.compile(getResourceString(KEY_PRE_PROCESS_PATTERN)); } catch (MissingResourceException exc) { return DateTypeConverter.PRE_PROCESS_PATTERN; } } /** * Pre-processes the input String to improve the chances of parsing it. First uses the regular * expression Pattern returned by getPreProcessPattern() to remove all separator chars and * ensure that components are separated by single spaces. Then invokes * {@link #checkAndAppendYear(String)} to append the year to the date in case the date * is in a format like "12/25" which would otherwise fail to parse. */ protected String preProcessInput(String input) { input = getPreProcessPattern().matcher(input.trim()).replaceAll(" "); input = checkAndAppendYear(input); return input; } /** * Checks to see how many 'parts' there are to the date (separated by spaces) and * if there are only two parts it adds the current year to the end by geting the * Locale specific year string from a Calendar instance. * * @param input the date string after the pre-process pattern has been run against it * @return either the date string as is, or with the year appended to the end */ protected String checkAndAppendYear(String input) { // Count the spaces, date components = spaces + 1 int count = 0; for (char ch : input.toCharArray()) { if (ch == ' ') ++count; } // Looks like we probably only have a day and month component, that won't work! if (count == 1) { input += " " + Calendar.getInstance(locale).get(Calendar.YEAR); } return input; } /** Convenience method to fetch a property from the resource bundle. */ protected String getResourceString(String key) throws MissingResourceException { return StripesFilter.getConfiguration().getLocalizationBundleFactory() .getErrorMessageBundle(locale).getString(key); } }