/*
* Geotoolkit - An Open Source Java GIS Toolkit
* http://www.geotoolkit.org
*
* (C) 2010, Open Source Geospatial Foundation (OSGeo)
* (C) 2010, Geomatys
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotoolkit.temporal.util;
import java.text.DateFormat;
import java.text.ParsePosition;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.StringTokenizer;
import java.util.TimeZone;
import org.geotoolkit.resources.Errors;
import org.geotoolkit.util.logging.LoggedFormat;
/**
* Utility methods to parse {@code java.lang.String} objects which describe
* instants or periods in the ISO-8601 format
* (e.g. {@code 2009-01-20T17:04:00Z} ) into {@link java.util.Date} objects.
* <p>
* TODO: Explain relationship to {@code DateFormat} and to {@code Util}.
* </p>
*
* <p>
* TODO: Improve and extend to handle the simple cases.
* </p>
*
* @author Cédric Briançon (Geomatys)
* @author Martin Desruisseaux (Geomatys)
* @author Adrian Custer (Geomatys)
*/
public final class TimeParser {
/**
* Amount of milliseconds in a day.
*/
static final long MILLIS_IN_DAY = 24*60*60*1000;
/**
* All patterns that are correct regarding the ISO-8601 norm.
*/
private static final String[] PATTERNS = {
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd'T'HH:mm'Z'",
"yyyy-MM-dd'T'HH:mm",
"yyyy-MM-dd'T'HH'Z'",
"yyyy-MM-dd'T'HH",
"yyyy-MM-dd",
"yyyy-MM",
"yyyy"
};
/**
* The date format for each pattern.
*/
private static final Map<TimeParser,DateFormat> PARSERS = new HashMap<TimeParser,DateFormat>(16);
static {
final TimeZone timezone = TimeZone.getTimeZone("UTC");
for (int i=0; i<PATTERNS.length; i++) {
final String pattern = PATTERNS[i];
final DateFormat format = new SimpleDateFormat(pattern, Locale.CANADA);
format.setTimeZone(timezone);
if (PARSERS.put(new TimeParser(pattern), format) != null) {
throw new AssertionError(pattern); // Should never occurs.
}
if (i == 0) { // The default format to be used if none if found.
PARSERS.put(null, format);
}
}
}
/**
* The number of date fields.
*/
private final int numDateFields;
/**
* The number of time fields, including milliseconds (preceded by {@code '.'} separator
* instead of {@code ':'}).
*/
private final int numTimeFields;
/**
* {@code true} if there is a time zone.
*/
private final boolean hasTimeZone;
/**
* Creates the parser for the given pattern (which may be a formatted date).
*/
private TimeParser(final String pattern) {
final int length = pattern.length();
int numDateFields = 1;
int numTimeFields = 0;
boolean hasTimeZone = false;
for (int i=0; i<length; i++) {
switch (pattern.charAt(i)) {
case '-': if (numTimeFields == 0) numDateFields++; break;
case 'T': if (numTimeFields == 0) numTimeFields=1; break;
case ':': if (numTimeFields != 0) numTimeFields++; break;
case '.': if (numTimeFields >= 3) numTimeFields++; break;
case 'Z': if (numTimeFields != 0) hasTimeZone=true; break;
default : break;
}
}
this.numDateFields = numDateFields;
this.numTimeFields = numTimeFields;
this.hasTimeZone = hasTimeZone;
}
/**
* Parses the date given in parameter. The date format should comply to ISO-8601 standard.
* The string may contain either a single date or a start time, an end time and a period.
* In the first case, this method returns a singleton containing only the parsed date. In
* the second case, this method returns a list including all dates from start time up to
* the end time with the interval specified in the {@code value} string.
*
* @param value The date, time and period to parse.
* @param defaultPeriod The default period (in milliseconds) if it is needed but not specified.
* If equal to 0, the period will be composed only with start and end date.
* @param dates The destination list where to append the parsed dates.
* @throws ParseException if the string can not be parsed.
*/
public static void parse(String value, final long defaultPeriod, final List<Date> dates)
throws ParseException
{
if (value == null) {
return;
}
value = value.trim();
if (value.length() == 0) {
return;
}
final StringTokenizer periods = new StringTokenizer(value, ",");
while (periods.hasMoreTokens()) {
final StringTokenizer elements = new StringTokenizer(periods.nextToken().trim(), "/");
if (!elements.hasMoreTokens()) {
// Empty string possibly between two "/" (should not occurs)
continue;
}
final Date start = parseDate(elements.nextToken());
if (!elements.hasMoreTokens()) {
// A single date is specified (most common case).
dates.add(start);
continue;
}
// Period like "yyyy-MM-ddTHH:mm:ssZ/yyyy-MM-ddTHH:mm:ssZ/P1D"
final Date end = parseDate(elements.nextToken());
final long period;
if (elements.hasMoreTokens()) {
period = parsePeriod(elements.nextToken());
} else {
period = defaultPeriod;
}
long time = start.getTime();
final long endTime = end.getTime();
if (period <= 0) {
dates.add(start);
dates.add(end);
} else {
while (time <= endTime) {
dates.add(new Date(time));
time += period;
}
}
}
}
/**
* Parses date given in parameter according the ISO-8601 standard. This parameter
* should follow a syntax defined in the {@link #PATTERNS} array to be validated.
*
* @param value The date to parse.
* @return A date found in the request.
* @throws ParseException if the string can not be parsed.
*/
private static Date parseDate(String value) throws ParseException {
value = value.trim();
DateFormat format = PARSERS.get(new TimeParser(value));
if (format == null) {
// Gets a default format.
format = PARSERS.get(null);
}
/*
* We do not use the standard method DateFormat.parse(String), because if the parsing
* stops before the end of the string, the remaining characters are just ignored and
* no exception is thrown. So we have to ensure that the whole string is correct for
* the format.
*/
final ParsePosition position = new ParsePosition(0);
final Date time;
synchronized (format) {
time = format.parse(value, position);
}
final int index = position.getIndex();
final int length = value.length();
if (index != length) {
final int errorIndex = Math.max(index, position.getErrorIndex());
throw new ParseException(LoggedFormat.formatUnparsable(value, index, errorIndex, null), errorIndex);
}
return time;
}
/**
* Parses the increment part of a period and returns it in milliseconds.
*
* @param period A string representation of the time increment according the ISO-8601:1988(E)
* standard. For example: {@code "P1D"} = one day.
* @return The increment value converted in milliseconds.
* @throws ParseException if the string can not be parsed.
*
* @todo Handle months in a better way than just taking the average month length.
*/
static long parsePeriod(final String period) throws ParseException {
final int length = period.length();
if (length!=0 && Character.toUpperCase(period.charAt(0)) != 'P') {
throw new ParseException(Errors.format(Errors.Keys.UnparsableString_2,
period, period.substring(0,1)), 0);
}
long millis = 0;
boolean time = false;
int lower = 0;
while (++lower < length) {
char letter = Character.toUpperCase(period.charAt(lower));
if (letter == 'T') {
time = true;
if (++lower >= length) {
break;
}
}
int upper = lower;
letter = period.charAt(upper);
while (!Character.isLetter(letter) || letter == 'e' || letter == 'E') {
if (++upper >= length) {
throw new ParseException(Errors.format(Errors.Keys.UnexpectedEndOfString), lower);
}
letter = period.charAt(upper);
}
letter = Character.toUpperCase(letter);
final String number = period.substring(lower, upper);
final double value;
try {
value = Double.parseDouble(number);
} catch (NumberFormatException exception) {
final ParseException e = new ParseException(Errors.format(
Errors.Keys.UnparsableNumber_1, number), lower);
e.initCause(exception);
throw e;
}
final double factor;
if (time) {
switch (letter) {
case 'S': factor = 1000; break;
case 'M': factor = 60*1000; break;
case 'H': factor = 60*60*1000; break;
default: throw new ParseException("Unknown time symbol: " + letter, upper);
}
} else {
switch (letter) {
case 'D': factor = MILLIS_IN_DAY; break;
case 'W': factor = 7 * MILLIS_IN_DAY; break;
case 'M': factor = 30 * MILLIS_IN_DAY; break;
case 'Y': factor = 365.25 * MILLIS_IN_DAY; break;
default: throw new ParseException("Unknown period symbol: " + letter, upper);
}
}
millis += Math.round(value * factor);
lower = upper;
}
return millis;
}
/**
* Convert a string containing a date into a {@link Date}, respecting the ISO 8601 standard.
*
* @param strTime Date as a string.
* @return A date parsed from a string, or {@code null} if it doesn't respect the ISO 8601.
* @throws java.text.ParseException
*/
public static Date toDate(final String strTime) throws ParseException {
if (strTime == null) {
return null;
}
final List<Date> dates = new ArrayList<Date>();
TimeParser.parse(strTime, 0L, dates);
return !dates.isEmpty() ? dates.get(0) : null;
}
/**
* Required for internal working only.
*/
@Override
public boolean equals(final Object other) {
if (other instanceof TimeParser) {
final TimeParser that = (TimeParser) other;
return this.numDateFields == that.numDateFields &&
this.numTimeFields == that.numTimeFields &&
this.hasTimeZone == that.hasTimeZone;
}
return false;
}
/**
* Required for internal working only.
*/
@Override
public int hashCode() {
// For hasTimeZone we use the same values than Boolean.hashCode().
return numDateFields + 37*numTimeFields + (hasTimeZone ? 1231 : 1237);
}
/**
* Returns a string representation for debugging purpose.
*/
@Override
public String toString() {
final StringBuilder builder = new StringBuilder(getClass().getSimpleName());
builder.append('[').append(numDateFields).append(',').append(numTimeFields);
final DateFormat format = PARSERS.get(this);
if (format instanceof SimpleDateFormat) {
builder.append(",\"").append(((SimpleDateFormat) format).toPattern()).append('"');
}
return builder.append(']').toString();
}
}