blob: 238da7aa593fce09b876449625f8ca436dcdf2c6 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 freemarker.template.utility;
import java.text.ParseException;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Locale;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Date and time related utilities.
*/
public class DateUtil {
/**
* Show hours (24h); always 2 digits, like {@code 00}, {@code 05}, etc.
*/
public static final int ACCURACY_HOURS = 4;
/**
* Show hours and minutes (even if minutes is 00).
*/
public static final int ACCURACY_MINUTES = 5;
/**
* Show hours, minutes and seconds (even if seconds is 00).
*/
public static final int ACCURACY_SECONDS = 6;
/**
* Show hours, minutes and seconds and up to 3 fraction second digits, without trailing 0-s in the fraction part.
*/
public static final int ACCURACY_MILLISECONDS = 7;
/**
* Show hours, minutes and seconds and exactly 3 fraction second digits (even if it's 000)
*/
public static final int ACCURACY_MILLISECONDS_FORCED = 8;
public static final TimeZone UTC = TimeZone.getTimeZone("UTC");
private static final String REGEX_XS_TIME_ZONE
= "Z|(?:[-+][0-9]{2}:[0-9]{2})";
private static final String REGEX_ISO8601_BASIC_TIME_ZONE
= "Z|(?:[-+][0-9]{2}(?:[0-9]{2})?)";
private static final String REGEX_ISO8601_EXTENDED_TIME_ZONE
= "Z|(?:[-+][0-9]{2}(?::[0-9]{2})?)";
private static final String REGEX_XS_OPTIONAL_TIME_ZONE
= "(" + REGEX_XS_TIME_ZONE + ")?";
private static final String REGEX_ISO8601_BASIC_OPTIONAL_TIME_ZONE
= "(" + REGEX_ISO8601_BASIC_TIME_ZONE + ")?";
private static final String REGEX_ISO8601_EXTENDED_OPTIONAL_TIME_ZONE
= "(" + REGEX_ISO8601_EXTENDED_TIME_ZONE + ")?";
private static final String REGEX_XS_DATE_BASE
= "(-?[0-9]+)-([0-9]{2})-([0-9]{2})";
private static final String REGEX_ISO8601_BASIC_DATE_BASE
= "(-?[0-9]{4,}?)([0-9]{2})([0-9]{2})";
private static final String REGEX_ISO8601_EXTENDED_DATE_BASE
= "(-?[0-9]{4,})-([0-9]{2})-([0-9]{2})";
private static final String REGEX_XS_TIME_BASE
= "([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\\.([0-9]+))?";
private static final String REGEX_ISO8601_BASIC_TIME_BASE
= "([0-9]{2})(?:([0-9]{2})(?:([0-9]{2})(?:[\\.,]([0-9]+))?)?)?";
private static final String REGEX_ISO8601_EXTENDED_TIME_BASE
= "([0-9]{2})(?::([0-9]{2})(?::([0-9]{2})(?:[\\.,]([0-9]+))?)?)?";
private static final Pattern PATTERN_XS_DATE = Pattern.compile(
REGEX_XS_DATE_BASE + REGEX_XS_OPTIONAL_TIME_ZONE);
private static final Pattern PATTERN_ISO8601_BASIC_DATE = Pattern.compile(
REGEX_ISO8601_BASIC_DATE_BASE); // No time zone allowed here
private static final Pattern PATTERN_ISO8601_EXTENDED_DATE = Pattern.compile(
REGEX_ISO8601_EXTENDED_DATE_BASE); // No time zone allowed here
private static final Pattern PATTERN_XS_TIME = Pattern.compile(
REGEX_XS_TIME_BASE + REGEX_XS_OPTIONAL_TIME_ZONE);
private static final Pattern PATTERN_ISO8601_BASIC_TIME = Pattern.compile(
REGEX_ISO8601_BASIC_TIME_BASE + REGEX_ISO8601_BASIC_OPTIONAL_TIME_ZONE);
private static final Pattern PATTERN_ISO8601_EXTENDED_TIME = Pattern.compile(
REGEX_ISO8601_EXTENDED_TIME_BASE + REGEX_ISO8601_EXTENDED_OPTIONAL_TIME_ZONE);
private static final Pattern PATTERN_XS_DATE_TIME = Pattern.compile(
REGEX_XS_DATE_BASE
+ "T" + REGEX_XS_TIME_BASE
+ REGEX_XS_OPTIONAL_TIME_ZONE);
private static final Pattern PATTERN_ISO8601_BASIC_DATE_TIME = Pattern.compile(
REGEX_ISO8601_BASIC_DATE_BASE
+ "T" + REGEX_ISO8601_BASIC_TIME_BASE
+ REGEX_ISO8601_BASIC_OPTIONAL_TIME_ZONE);
private static final Pattern PATTERN_ISO8601_EXTENDED_DATE_TIME = Pattern.compile(
REGEX_ISO8601_EXTENDED_DATE_BASE
+ "T" + REGEX_ISO8601_EXTENDED_TIME_BASE
+ REGEX_ISO8601_EXTENDED_OPTIONAL_TIME_ZONE);
private static final Pattern PATTERN_XS_TIME_ZONE = Pattern.compile(
REGEX_XS_TIME_ZONE);
private static final String MSG_YEAR_0_NOT_ALLOWED
= "Year 0 is not allowed in XML schema dates. BC 1 is -1, AD 1 is 1.";
private DateUtil() {
// can't be instantiated
}
/**
* Returns the time zone object for the name (or ID). This differs from
* {@link TimeZone#getTimeZone(String)} in that the latest returns GMT
* if it doesn't recognize the name, while this throws an
* {@link UnrecognizedTimeZoneException}.
*
* @throws UnrecognizedTimeZoneException If the time zone name wasn't understood
*/
public static TimeZone getTimeZone(String name)
throws UnrecognizedTimeZoneException {
if (isGMTish(name)) {
if (name.equalsIgnoreCase("UTC")) {
return UTC;
}
return TimeZone.getTimeZone(name);
}
TimeZone tz = TimeZone.getTimeZone(name);
if (isGMTish(tz.getID())) {
throw new UnrecognizedTimeZoneException(name);
}
return tz;
}
/**
* Tells if a offset or time zone is GMT. GMT is a fuzzy term, it used to
* referred both to UTC and UT1.
*/
private static boolean isGMTish(String name) {
if (name.length() < 3) {
return false;
}
char c1 = name.charAt(0);
char c2 = name.charAt(1);
char c3 = name.charAt(2);
if (
!(
(c1 == 'G' || c1 == 'g')
&& (c2 == 'M' || c2 == 'm')
&& (c3 == 'T' || c3 == 't')
)
&&
!(
(c1 == 'U' || c1 == 'u')
&& (c2 == 'T' || c2 == 't')
&& (c3 == 'C' || c3 == 'c')
)
&&
!(
(c1 == 'U' || c1 == 'u')
&& (c2 == 'T' || c2 == 't')
&& (c3 == '1')
)
) {
return false;
}
if (name.length() == 3) {
return true;
}
String offset = name.substring(3);
if (offset.startsWith("+")) {
return offset.equals("+0") || offset.equals("+00")
|| offset.equals("+00:00");
} else {
return offset.equals("-0") || offset.equals("-00")
|| offset.equals("-00:00");
}
}
/**
* Format a date, time or dateTime with one of the ISO 8601 extended
* formats that is also compatible with the XML Schema format (as far as you
* don't have dates in the BC era). Examples of possible outputs:
* {@code "2005-11-27T15:30:00+02:00"}, {@code "2005-11-27"},
* {@code "15:30:00Z"}. Note the {@code ":00"} in the time zone offset;
* this is not required by ISO 8601, but included for compatibility with
* the XML Schema format. Regarding the B.C. issue, those dates will be
* one year off when read back according the XML Schema format, because of a
* mismatch between that format and ISO 8601:2000 Second Edition.
*
* <p>This method is thread-safe.
*
* @param date the date to convert to ISO 8601 string
* @param datePart whether the date part (year, month, day) will be included
* or not
* @param timePart whether the time part (hours, minutes, seconds,
* milliseconds) will be included or not
* @param offsetPart whether the time zone offset part will be included or
* not. This will be shown as an offset to UTC (examples:
* {@code "+01"}, {@code "-02"}, {@code "+04:30"}) or as {@code "Z"}
* for UTC (and for UT1 and for GMT+00, since the Java platform
* doesn't really care about the difference).
* Note that this can't be {@code true} when {@code timePart} is
* {@code false}, because ISO 8601 (2004) doesn't mention such
* patterns.
* @param accuracy tells which parts of the date/time to drop. The
* {@code datePart} and {@code timePart} parameters are stronger than
* this. Note that when {@link #ACCURACY_MILLISECONDS} is specified,
* the milliseconds part will be displayed as fraction seconds
* (like {@code "15:30.00.25"}) with the minimum number of
* digits needed to show the milliseconds without precision lose.
* Thus, if the milliseconds happen to be exactly 0, no fraction
* seconds will be shown at all.
* @param timeZone the time zone in which the date/time will be shown. (You
* may find {@link DateUtil#UTC} handy here.) Note
* that although date-only formats has no time zone offset part,
* the result still depends on the time zone, as days start and end
* at different points on the time line in different zones.
* @param calendarFactory the factory that will create the calendar used
* internally for calculations. The point of this parameter is that
* creating a new calendar is relatively expensive, so it's desirable
* to reuse calendars and only set their time and zone. (This was
* tested on Sun JDK 1.6 x86 Win, where it gave 2x-3x speedup.)
*/
public static String dateToISO8601String(
Date date,
boolean datePart, boolean timePart, boolean offsetPart,
int accuracy,
TimeZone timeZone,
DateToISO8601CalendarFactory calendarFactory) {
return dateToString(date, datePart, timePart, offsetPart, accuracy, timeZone, false, calendarFactory);
}
/**
* Same as {@link #dateToISO8601String}, but gives XML Schema compliant format.
*/
public static String dateToXSString(
Date date,
boolean datePart, boolean timePart, boolean offsetPart,
int accuracy,
TimeZone timeZone,
DateToISO8601CalendarFactory calendarFactory) {
return dateToString(date, datePart, timePart, offsetPart, accuracy, timeZone, true, calendarFactory);
}
private static String dateToString(
Date date,
boolean datePart, boolean timePart, boolean offsetPart,
int accuracy,
TimeZone timeZone, boolean xsMode,
DateToISO8601CalendarFactory calendarFactory) {
if (!xsMode && !timePart && offsetPart) {
throw new IllegalArgumentException(
"ISO 8601:2004 doesn't specify any formats where the "
+ "offset is shown but the time isn't.");
}
if (timeZone == null) {
timeZone = UTC;
}
GregorianCalendar cal = calendarFactory.get(timeZone, date);
int maxLength;
if (!timePart) {
maxLength = 10 + (xsMode ? 6 : 0); // YYYY-MM-DD+00:00
} else {
if (!datePart) {
maxLength = 12 + 6; // HH:MM:SS.mmm+00:00
} else {
maxLength = 10 + 1 + 12 + 6;
}
}
char[] res = new char[maxLength];
int dstIdx = 0;
if (datePart) {
int x = cal.get(Calendar.YEAR);
if (x > 0 && cal.get(Calendar.ERA) == GregorianCalendar.BC) {
x = -x + (xsMode ? 0 : 1);
}
if (x >= 0 && x < 9999) {
res[dstIdx++] = (char) ('0' + x / 1000);
res[dstIdx++] = (char) ('0' + x % 1000 / 100);
res[dstIdx++] = (char) ('0' + x % 100 / 10);
res[dstIdx++] = (char) ('0' + x % 10);
} else {
String yearString = String.valueOf(x);
// Re-allocate buffer:
maxLength = maxLength - 4 + yearString.length();
res = new char[maxLength];
for (int i = 0; i < yearString.length(); i++) {
res[dstIdx++] = yearString.charAt(i);
}
}
res[dstIdx++] = '-';
x = cal.get(Calendar.MONTH) + 1;
dstIdx = append00(res, dstIdx, x);
res[dstIdx++] = '-';
x = cal.get(Calendar.DAY_OF_MONTH);
dstIdx = append00(res, dstIdx, x);
if (timePart) {
res[dstIdx++] = 'T';
}
}
if (timePart) {
int x = cal.get(Calendar.HOUR_OF_DAY);
dstIdx = append00(res, dstIdx, x);
if (accuracy >= ACCURACY_MINUTES) {
res[dstIdx++] = ':';
x = cal.get(Calendar.MINUTE);
dstIdx = append00(res, dstIdx, x);
if (accuracy >= ACCURACY_SECONDS) {
res[dstIdx++] = ':';
x = cal.get(Calendar.SECOND);
dstIdx = append00(res, dstIdx, x);
if (accuracy >= ACCURACY_MILLISECONDS) {
x = cal.get(Calendar.MILLISECOND);
int forcedDigits = accuracy == ACCURACY_MILLISECONDS_FORCED ? 3 : 0;
if (x != 0 || forcedDigits != 0) {
if (x > 999) {
// Shouldn't ever happen...
throw new RuntimeException(
"Calendar.MILLISECOND > 999");
}
res[dstIdx++] = '.';
do {
res[dstIdx++] = (char) ('0' + (x / 100));
forcedDigits--;
x = x % 100 * 10;
} while (x != 0 || forcedDigits > 0);
}
}
}
}
}
if (offsetPart) {
if (timeZone == UTC) {
res[dstIdx++] = 'Z';
} else {
int dt = timeZone.getOffset(date.getTime());
boolean positive;
if (dt < 0) {
positive = false;
dt = -dt;
} else {
positive = true;
}
dt /= 1000;
int offS = dt % 60;
dt /= 60;
int offM = dt % 60;
dt /= 60;
int offH = dt;
if (offS == 0 && offM == 0 && offH == 0) {
res[dstIdx++] = 'Z';
} else {
res[dstIdx++] = positive ? '+' : '-';
dstIdx = append00(res, dstIdx, offH);
res[dstIdx++] = ':';
dstIdx = append00(res, dstIdx, offM);
if (offS != 0) {
res[dstIdx++] = ':';
dstIdx = append00(res, dstIdx, offS);
}
}
}
}
return new String(res, 0, dstIdx);
}
/**
* Appends a number between 0 and 99 padded to 2 digits.
*/
private static int append00(char[] res, int dstIdx, int x) {
res[dstIdx++] = (char) ('0' + x / 10);
res[dstIdx++] = (char) ('0' + x % 10);
return dstIdx;
}
/**
* Parses an W3C XML Schema date string (not time or date-time).
* Unlike in ISO 8601:2000 Second Edition, year -1 means B.C 1, and year 0 is invalid.
*
* @param dateStr the string to parse.
* @param defaultTimeZone used if the date doesn't specify the
* time zone offset explicitly. Can't be {@code null}.
* @param calToDateConverter Used internally to calculate the result from the calendar field values.
* If you don't have a such object around, you can just use
* {@code new }{@link TrivialCalendarFieldsToDateConverter}{@code ()}.
*
* @throws DateParseException if the date is malformed, or if the time
* zone offset is unspecified and the {@code defaultTimeZone} is
* {@code null}.
*/
public static Date parseXSDate(
String dateStr, TimeZone defaultTimeZone,
CalendarFieldsToDateConverter calToDateConverter)
throws DateParseException {
Matcher m = PATTERN_XS_DATE.matcher(dateStr);
if (!m.matches()) {
throw new DateParseException("The value didn't match the expected pattern: " + PATTERN_XS_DATE);
}
return parseDate_parseMatcher(
m, defaultTimeZone, true, calToDateConverter);
}
/**
* Same as {@link #parseXSDate(String, TimeZone, CalendarFieldsToDateConverter)}, but for ISO 8601 dates.
*/
public static Date parseISO8601Date(
String dateStr, TimeZone defaultTimeZone,
CalendarFieldsToDateConverter calToDateConverter)
throws DateParseException {
Matcher m = PATTERN_ISO8601_EXTENDED_DATE.matcher(dateStr);
if (!m.matches()) {
m = PATTERN_ISO8601_BASIC_DATE.matcher(dateStr);
if (!m.matches()) {
throw new DateParseException("The value didn't match the expected pattern: "
+ PATTERN_ISO8601_EXTENDED_DATE + " or "
+ PATTERN_ISO8601_BASIC_DATE);
}
}
return parseDate_parseMatcher(
m, defaultTimeZone, false, calToDateConverter);
}
private static Date parseDate_parseMatcher(
Matcher m, TimeZone defaultTZ,
boolean xsMode,
CalendarFieldsToDateConverter calToDateConverter)
throws DateParseException {
NullArgumentException.check("defaultTZ", defaultTZ);
try {
int year = groupToInt(m.group(1), "year", Integer.MIN_VALUE, Integer.MAX_VALUE);
int era;
// Starting from ISO 8601:2000 Second Edition, 0001 is AD 1, 0000 is BC 1, -0001 is BC 2.
// However, according to http://www.w3.org/TR/2004/REC-xmlschema-2-20041028/, XML schemas are based
// on the earlier version where 0000 didn't exist, and year -1 is BC 1.
if (year <= 0) {
era = GregorianCalendar.BC;
year = -year + (xsMode ? 0 : 1);
if (year == 0) {
throw new DateParseException(MSG_YEAR_0_NOT_ALLOWED);
}
} else {
era = GregorianCalendar.AD;
}
int month = groupToInt(m.group(2), "month", 1, 12) - 1;
int day = groupToInt(m.group(3), "day-of-month", 1, 31);
TimeZone tz = xsMode ? parseMatchingTimeZone(m.group(4), defaultTZ) : defaultTZ;
return calToDateConverter.calculate(era, year, month, day, 0, 0, 0, 0, false, tz);
} catch (IllegalArgumentException e) {
// Calendar methods used to throw this for illegal dates.
throw new DateParseException(
"Date calculation faliure. "
+ "Probably the date is formally correct, but refers "
+ "to an unexistent date (like February 30).");
}
}
/**
* Parses an W3C XML Schema time string (not date or date-time).
* If the time string doesn't specify the time zone offset explicitly,
* the value of the {@code defaultTZ} paramter will be used.
*/
public static Date parseXSTime(
String timeStr, TimeZone defaultTZ, CalendarFieldsToDateConverter calToDateConverter)
throws DateParseException {
Matcher m = PATTERN_XS_TIME.matcher(timeStr);
if (!m.matches()) {
throw new DateParseException("The value didn't match the expected pattern: " + PATTERN_XS_TIME);
}
return parseTime_parseMatcher(m, defaultTZ, calToDateConverter);
}
/**
* Same as {@link #parseXSTime(String, TimeZone, CalendarFieldsToDateConverter)} but for ISO 8601 times.
*/
public static Date parseISO8601Time(
String timeStr, TimeZone defaultTZ, CalendarFieldsToDateConverter calToDateConverter)
throws DateParseException {
Matcher m = PATTERN_ISO8601_EXTENDED_TIME.matcher(timeStr);
if (!m.matches()) {
m = PATTERN_ISO8601_BASIC_TIME.matcher(timeStr);
if (!m.matches()) {
throw new DateParseException("The value didn't match the expected pattern: "
+ PATTERN_ISO8601_EXTENDED_TIME + " or "
+ PATTERN_ISO8601_BASIC_TIME);
}
}
return parseTime_parseMatcher(m, defaultTZ, calToDateConverter);
}
private static Date parseTime_parseMatcher(
Matcher m, TimeZone defaultTZ,
CalendarFieldsToDateConverter calToDateConverter)
throws DateParseException {
NullArgumentException.check("defaultTZ", defaultTZ);
try {
// ISO 8601 allows both 00:00 and 24:00,
// but Calendar.set(...) doesn't if the Calendar is not lenient.
int hours = groupToInt(m.group(1), "hour-of-day", 0, 24);
boolean hourWas24;
if (hours == 24) {
hours = 0;
hourWas24 = true;
// And a day will be added later...
} else {
hourWas24 = false;
}
final String minutesStr = m.group(2);
int minutes = minutesStr != null ? groupToInt(minutesStr, "minute", 0, 59) : 0;
final String secsStr = m.group(3);
// Allow 60 because of leap seconds
int secs = secsStr != null ? groupToInt(secsStr, "second", 0, 60) : 0;
int millisecs = groupToMillisecond(m.group(4));
// As a time is just the distance from the beginning of the day,
// the time-zone offest should be 0 usually.
TimeZone tz = parseMatchingTimeZone(m.group(5), defaultTZ);
// Continue handling the 24:00 special case
int day;
if (hourWas24) {
if (minutes == 0 && secs == 0 && millisecs == 0) {
day = 2;
} else {
throw new DateParseException(
"Hour 24 is only allowed in the case of "
+ "midnight.");
}
} else {
day = 1;
}
return calToDateConverter.calculate(
GregorianCalendar.AD, 1970, 0, day, hours, minutes, secs, millisecs, false, tz);
} catch (IllegalArgumentException e) {
// Calendar methods used to throw this for illegal dates.
throw new DateParseException(
"Unexpected time calculation faliure.");
}
}
/**
* Parses an W3C XML Schema date-time string (not date or time).
* Unlike in ISO 8601:2000 Second Edition, year -1 means B.C 1, and year 0 is invalid.
*
* @param dateTimeStr the string to parse.
* @param defaultTZ used if the dateTime doesn't specify the
* time zone offset explicitly. Can't be {@code null}.
*
* @throws DateParseException if the dateTime is malformed.
*/
public static Date parseXSDateTime(
String dateTimeStr, TimeZone defaultTZ, CalendarFieldsToDateConverter calToDateConverter)
throws DateParseException {
Matcher m = PATTERN_XS_DATE_TIME.matcher(dateTimeStr);
if (!m.matches()) {
throw new DateParseException(
"The value didn't match the expected pattern: " + PATTERN_XS_DATE_TIME);
}
return parseDateTime_parseMatcher(
m, defaultTZ, true, calToDateConverter);
}
/**
* Same as {@link #parseXSDateTime(String, TimeZone, CalendarFieldsToDateConverter)} but for ISO 8601 format.
*/
public static Date parseISO8601DateTime(
String dateTimeStr, TimeZone defaultTZ, CalendarFieldsToDateConverter calToDateConverter)
throws DateParseException {
Matcher m = PATTERN_ISO8601_EXTENDED_DATE_TIME.matcher(dateTimeStr);
if (!m.matches()) {
m = PATTERN_ISO8601_BASIC_DATE_TIME.matcher(dateTimeStr);
if (!m.matches()) {
throw new DateParseException("The value (" + dateTimeStr + ") didn't match the expected pattern: "
+ PATTERN_ISO8601_EXTENDED_DATE_TIME + " or "
+ PATTERN_ISO8601_BASIC_DATE_TIME);
}
}
return parseDateTime_parseMatcher(
m, defaultTZ, false, calToDateConverter);
}
private static Date parseDateTime_parseMatcher(
Matcher m, TimeZone defaultTZ,
boolean xsMode,
CalendarFieldsToDateConverter calToDateConverter)
throws DateParseException {
NullArgumentException.check("defaultTZ", defaultTZ);
try {
int year = groupToInt(m.group(1), "year", Integer.MIN_VALUE, Integer.MAX_VALUE);
int era;
// Starting from ISO 8601:2000 Second Edition, 0001 is AD 1, 0000 is BC 1, -0001 is BC 2.
// However, according to http://www.w3.org/TR/2004/REC-xmlschema-2-20041028/, XML schemas are based
// on the earlier version where 0000 didn't exist, and year -1 is BC 1.
if (year <= 0) {
era = GregorianCalendar.BC;
year = -year + (xsMode ? 0 : 1);
if (year == 0) {
throw new DateParseException(MSG_YEAR_0_NOT_ALLOWED);
}
} else {
era = GregorianCalendar.AD;
}
int month = groupToInt(m.group(2), "month", 1, 12) - 1;
int day = groupToInt(m.group(3), "day-of-month", 1, 31);
// ISO 8601 allows both 00:00 and 24:00,
// but cal.set(...) doesn't if the Calendar is not lenient.
int hours = groupToInt(m.group(4), "hour-of-day", 0, 24);
boolean hourWas24;
if (hours == 24) {
hours = 0;
hourWas24 = true;
// And a day will be added later...
} else {
hourWas24 = false;
}
final String minutesStr = m.group(5);
int minutes = minutesStr != null ? groupToInt(minutesStr, "minute", 0, 59) : 0;
final String secsStr = m.group(6);
// Allow 60 because of leap seconds
int secs = secsStr != null ? groupToInt(secsStr, "second", 0, 60) : 0;
int millisecs = groupToMillisecond(m.group(7));
// As a time is just the distance from the beginning of the day,
// the time-zone offest should be 0 usually.
TimeZone tz = parseMatchingTimeZone(m.group(8), defaultTZ);
// Continue handling the 24:00 specail case
if (hourWas24) {
if (minutes != 0 || secs != 0 || millisecs != 0) {
throw new DateParseException(
"Hour 24 is only allowed in the case of "
+ "midnight.");
}
}
return calToDateConverter.calculate(
era, year, month, day, hours, minutes, secs, millisecs, hourWas24, tz);
} catch (IllegalArgumentException e) {
// Calendar methods used to throw this for illegal dates.
throw new DateParseException(
"Date-time calculation faliure. "
+ "Probably the date-time is formally correct, but "
+ "refers to an unexistent date-time "
+ "(like February 30).");
}
}
/**
* Parses the time zone part from a W3C XML Schema date/time/dateTime.
* @throws DateParseException if the zone is malformed.
*/
public static TimeZone parseXSTimeZone(String timeZoneStr)
throws DateParseException {
Matcher m = PATTERN_XS_TIME_ZONE.matcher(timeZoneStr);
if (!m.matches()) {
throw new DateParseException(
"The time zone offset didn't match the expected pattern: " + PATTERN_XS_TIME_ZONE);
}
return parseMatchingTimeZone(timeZoneStr, null);
}
private static int groupToInt(String g, String gName,
int min, int max)
throws DateParseException {
if (g == null) {
throw new DateParseException("The " + gName + " part "
+ "is missing.");
}
int start;
// Remove minus sign, so we can remove the 0-s later:
boolean negative;
if (g.startsWith("-")) {
negative = true;
start = 1;
} else {
negative = false;
start = 0;
}
// Remove leading 0-s:
while (start < g.length() - 1 && g.charAt(start) == '0') {
start++;
}
if (start != 0) {
g = g.substring(start);
}
try {
int r = Integer.parseInt(g);
if (negative) {
r = -r;
}
if (r < min) {
throw new DateParseException("The " + gName + " part "
+ "must be at least " + min + ".");
}
if (r > max) {
throw new DateParseException("The " + gName + " part "
+ "can't be more than " + max + ".");
}
return r;
} catch (NumberFormatException e) {
throw new DateParseException("The " + gName + " part "
+ "is a malformed integer.");
}
}
private static TimeZone parseMatchingTimeZone(
String s, TimeZone defaultZone)
throws DateParseException {
if (s == null) {
return defaultZone;
}
if (s.equals("Z")) {
return DateUtil.UTC;
}
StringBuilder sb = new StringBuilder(9);
sb.append("GMT");
sb.append(s.charAt(0));
String h = s.substring(1, 3);
groupToInt(h, "offset-hours", 0, 23);
sb.append(h);
String m;
int ln = s.length();
if (ln > 3) {
int startIdx = s.charAt(3) == ':' ? 4 : 3;
m = s.substring(startIdx, startIdx + 2);
groupToInt(m, "offset-minutes", 0, 59);
sb.append(':');
sb.append(m);
}
return TimeZone.getTimeZone(sb.toString());
}
private static int groupToMillisecond(String g)
throws DateParseException {
if (g == null) {
return 0;
}
if (g.length() > 3) {
g = g.substring(0, 3);
}
int i = groupToInt(g, "partial-seconds", 0, Integer.MAX_VALUE);
return g.length() == 1 ? i * 100 : (g.length() == 2 ? i * 10 : i);
}
/**
* Used internally by {@link DateUtil}; don't use its implementations for
* anything else.
*/
public interface DateToISO8601CalendarFactory {
/**
* Returns a {@link GregorianCalendar} with the desired time zone and
* time and US locale. The returned calendar is used as read-only.
* It must be guaranteed that within a thread the instance returned last time
* is not in use anymore when this method is called again.
*/
GregorianCalendar get(TimeZone tz, Date date);
}
/**
* Used internally by {@link DateUtil}; don't use its implementations for anything else.
*/
public interface CalendarFieldsToDateConverter {
/**
* Calculates the {@link Date} from the specified calendar fields.
*/
Date calculate(int era, int year, int month, int day, int hours, int minutes, int secs, int millisecs,
boolean addOneDay,
TimeZone tz);
}
/**
* Non-thread-safe factory that hard-references a calendar internally.
*/
public static final class TrivialDateToISO8601CalendarFactory
implements DateToISO8601CalendarFactory {
private GregorianCalendar calendar;
private TimeZone lastlySetTimeZone;
public GregorianCalendar get(TimeZone tz, Date date) {
if (calendar == null) {
calendar = new GregorianCalendar(tz, Locale.US);
calendar.setGregorianChange(new Date(Long.MIN_VALUE)); // never use Julian calendar
} else {
// At least on Java 6, calendar.getTimeZone is slow due to a bug, so we need lastlySetTimeZone.
if (lastlySetTimeZone != tz) { // Deliberately `!=` instead of `!<...>.equals()`
calendar.setTimeZone(tz);
lastlySetTimeZone = tz;
}
}
calendar.setTime(date);
return calendar;
}
}
/**
* Non-thread-safe implementation that hard-references a calendar internally.
*/
public static final class TrivialCalendarFieldsToDateConverter
implements CalendarFieldsToDateConverter {
private GregorianCalendar calendar;
private TimeZone lastlySetTimeZone;
public Date calculate(int era, int year, int month, int day, int hours, int minutes, int secs, int millisecs,
boolean addOneDay, TimeZone tz) {
if (calendar == null) {
calendar = new GregorianCalendar(tz, Locale.US);
calendar.setLenient(false);
calendar.setGregorianChange(new Date(Long.MIN_VALUE)); // never use Julian calendar
} else {
// At least on Java 6, calendar.getTimeZone is slow due to a bug, so we need lastlySetTimeZone.
if (lastlySetTimeZone != tz) { // Deliberately `!=` instead of `!<...>.equals()`
calendar.setTimeZone(tz);
lastlySetTimeZone = tz;
}
}
calendar.set(Calendar.ERA, era);
calendar.set(Calendar.YEAR, year);
calendar.set(Calendar.MONTH, month);
calendar.set(Calendar.DAY_OF_MONTH, day);
calendar.set(Calendar.HOUR_OF_DAY, hours);
calendar.set(Calendar.MINUTE, minutes);
calendar.set(Calendar.SECOND, secs);
calendar.set(Calendar.MILLISECOND, millisecs);
if (addOneDay) {
calendar.add(Calendar.DAY_OF_MONTH, 1);
}
return calendar.getTime();
}
}
public static final class DateParseException extends ParseException {
public DateParseException(String message) {
super(message, 0);
}
}
}