/* (c) 2014 - 2016 Open Source Geospatial Foundation - all rights reserved * (c) 2001 - 2013 OpenPlans * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.ows.kvp; import java.text.ParseException; import java.text.ParsePosition; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.GregorianCalendar; import java.util.Iterator; import java.util.Set; import java.util.TimeZone; import java.util.TreeSet; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Pattern; import org.geoserver.platform.GeoServerExtensions; import org.geotools.util.DateRange; import org.geotools.util.logging.Logging; /** * Parses the {@code time} parameter of the request. The date, time and period * are expected to be formatted according ISO-8601 standard. * * @author Cedric Briancon * @author Martin Desruisseaux * @author Simone Giannecchini, GeoSolutions SAS * @author Jonathan Meyer, Applied Information Sciences, jon@gisjedi.com * @version $Id$ */ public class TimeParser { static final Logger LOGGER = Logging.getLogger(TimeParser.class); private static enum FormatAndPrecision { MILLISECOND("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Calendar.MILLISECOND), SECOND("yyyy-MM-dd'T'HH:mm:ss'Z'", Calendar.SECOND), MINUTE("yyyy-MM-dd'T'HH:mm'Z'", Calendar.MINUTE), HOUR("yyyy-MM-dd'T'HH'Z'", Calendar.HOUR_OF_DAY), DAY("yyyy-MM-dd", Calendar.DAY_OF_MONTH), MONTH("yyyy-MM", Calendar.MONTH), YEAR("yyyy", Calendar.YEAR); public final String format; public final int precision; FormatAndPrecision(final String format, int precision) { this.format = format; this.precision = precision; } public SimpleDateFormat getFormat() { SimpleDateFormat sdf = new SimpleDateFormat(format); sdf.setTimeZone(UTC_TZ); return sdf; } public DateRange expand(Date d) { Calendar c = new GregorianCalendar(UTC_TZ); c.setTime(d); c.add(this.precision, 1); c.add(Calendar.MILLISECOND, -1); return new DateRange(d, c.getTime()); } } /** * UTC timezone to serve as reference */ static final TimeZone UTC_TZ = TimeZone.getTimeZone("UTC"); /** * pattern used to match back parameter */ private static final Pattern pattern = Pattern.compile("(back)(\\d+)([hdw])"); /** * Amount of milliseconds in a day. */ static final long MILLIS_IN_DAY = 24*60*60*1000; /** * Built-in limits to avoid exploding on too large requests */ private final static int MAX_ELEMENTS_TIMES_KVP; private final static int DEFAULT_MAX_ELEMENTS_TIMES_KVP = 100; static { // initialization of the renderer choice flag String value = GeoServerExtensions.getProperty("MAX_ELEMENTS_TIMES_KVP"); // default to true, but allow switching on if (value == null) MAX_ELEMENTS_TIMES_KVP = DEFAULT_MAX_ELEMENTS_TIMES_KVP; else { int iVal = -1; try { iVal = Integer.parseInt(value.trim()); } catch (Exception e) { iVal = DEFAULT_MAX_ELEMENTS_TIMES_KVP; } MAX_ELEMENTS_TIMES_KVP = iVal; } } /** * Parses the date given in parameter. The date format should comply to * ISO-8601 standard. The string may contains either a single date, or * a start time, 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. * @return A list of dates, or an empty list of the {@code value} string * is null or empty. * @throws ParseException if the string can not be parsed. */ @SuppressWarnings({ "unchecked", "rawtypes" }) public Collection parse(String value) throws ParseException { if (value == null) { return Collections.emptyList(); } value = value.trim(); if (value.length() == 0) { return Collections.emptyList(); } final Set result = new TreeSet(new Comparator() { public int compare(Object o1, Object o2) { final boolean o1Date= o1 instanceof Date; final boolean o2Date= o2 instanceof Date; if(o1 == o2) { return 0; } // o1 date if(o1Date){ final Date dateLeft=(Date) o1; if(o2Date){ // o2 date return dateLeft.compareTo((Date) o2); } // o2 daterange return dateLeft.compareTo(((DateRange)o2).getMinValue()); } // o1 date range final DateRange left= (DateRange) o1; if(o2Date){ // o2 date return left.getMinValue().compareTo(((Date) o2)); } // o2 daterange return left.getMinValue().compareTo(((DateRange)o2).getMinValue()); } }); String[] listDates = value.split(","); for(String date: listDates){ // is it a date or a period? if(date.indexOf("/")<=0){ Object o = getFuzzyDate(date); if (o instanceof Date) { addDate(result, (Date)o); } else { addPeriod(result, (DateRange)o); } } else { // period String[] period = date.split("/"); // // Period like one of the following: // yyyy-MM-ddTHH:mm:ssZ/yyyy-MM-ddTHH:mm:ssZ/P1D // May be one of the following possible ISO 8601 Time Interval formats with trailing period for // breaking the interval by given period: // TIME/TIME/PERIOD // DURATION/TIME/PERIOD // TIME/DURATION/PERIOD // if (period.length == 3) { Date[] range = parseTimeDuration(period); final long millisIncrement = parsePeriod(period[2]); final long startTime = range[0].getTime(); final long endTime = range[1].getTime(); long time; int j = 0; while ((time = j * millisIncrement + startTime) <= endTime) { final Calendar calendar = new GregorianCalendar(UTC_TZ); calendar.setTimeInMillis(time); addDate(result, calendar.getTime()); j++; // limiting number of elements we can create if(j>= MAX_ELEMENTS_TIMES_KVP){ if(LOGGER.isLoggable(Level.INFO)) LOGGER.info("Lmiting number of elements in this periodo to "+MAX_ELEMENTS_TIMES_KVP); break; } } } // Period like : yyyy-MM-ddTHH:mm:ssZ/yyyy-MM-ddTHH:mm:ssZ, it is an extension // of WMS that works with continuos period [Tb, Te]. // May be one of the following possible ISO 8601 Time Interval formats, as in ECQL Time Period: // TIME/DURATION // DURATION/TIME // TIME/TIME else if (period.length == 2) { Date[] range = parseTimeDuration(period); addPeriod(result, new DateRange(range[0], range[1])); } else { throw new ParseException("Invalid time period: " + Arrays.toString(period), 0); } } } return new ArrayList(result); } private static Date[] parseTimeDuration(final String[] period) throws ParseException { Date[] range = null; if (period.length == 2 || period.length == 3) { Date begin = null; Date end = null; // Check first to see if we have any duration value within TIME parameter if (period[0].toUpperCase().startsWith("P") || period[1].toUpperCase().startsWith("P")) { long durationOffset = Long.MIN_VALUE; // Attempt to parse a time or duration from the first portion of the if (period[0].toUpperCase().startsWith("P")) { durationOffset = parsePeriod(period[0]); } else { begin = beginning(getFuzzyDate(period[0])); } if (period[1].toUpperCase().startsWith("P") && !period[1].toUpperCase().startsWith("PRESENT")) { // Invalid time period of the format: // DURATION/DURATION[/PERIOD] if (durationOffset != Long.MIN_VALUE) { throw new ParseException( "Invalid time period containing duration with no paired time value: " + Arrays.toString(period), 0); } // Time period of the format: // DURATION/TIME[/PERIOD] else { durationOffset = parsePeriod(period[1]); final Calendar calendar = new GregorianCalendar(); calendar.setTimeInMillis(begin.getTime() + durationOffset); end = calendar.getTime(); } } // Time period of the format: // TIME/DURATION[/PERIOD] else { end = end(getFuzzyDate(period[1])); final Calendar calendar = new GregorianCalendar(); calendar.setTimeInMillis(end.getTime() - durationOffset); begin = calendar.getTime(); } } // Time period of the format: // TIME/TIME[/PERIOD] else { begin = beginning(getFuzzyDate(period[0])); end = end(getFuzzyDate(period[1])); } range = new Date[2]; range[0] = begin; range[1] = end; } return range; } private static Date beginning(Object dateOrDateRange) { if (dateOrDateRange instanceof DateRange) { return ((DateRange) dateOrDateRange).getMinValue(); } else { return (Date) dateOrDateRange; } } private static Date end(Object dateOrDateRange) { if (dateOrDateRange instanceof DateRange) { return ((DateRange) dateOrDateRange).getMaxValue(); } else { return (Date) dateOrDateRange; } } /** * Tries to avoid insertion of multiple time values. * * @param result * @param newRange */ private static void addPeriod(Collection result, DateRange newRange) { for(Iterator it=result.iterator();it.hasNext();){ final Object element=it.next(); if(element instanceof Date){ // convert final Date local= (Date) element; if(newRange.contains(local)){ it.remove(); } } else { // convert final DateRange local= (DateRange) element; if(local.contains(newRange)) return; if(newRange.contains(local)) it.remove(); } } result.add(newRange); } private static void addDate(Collection result, Date newDate) { for (Iterator<?> it = result.iterator(); it.hasNext(); ) { final Object element = it.next(); if (element instanceof Date) { if (newDate.equals(element)) return; } else if (((DateRange) element).contains(newDate)) { return; } } result.add(newDate); } /** * 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. */ static Object getFuzzyDate(final String value) throws ParseException { String computedValue = value; // special handling for current keyword (we accept both wms and wcs ways) if (computedValue.equalsIgnoreCase("current") || computedValue.equalsIgnoreCase("now")) { return null; } // Accept new "present" keyword, which actually fills in present time as now should have if (computedValue.equalsIgnoreCase("present")) { Calendar now = Calendar.getInstance(); now.set(Calendar.MILLISECOND, 0); computedValue = FormatAndPrecision.MILLISECOND.getFormat().format(now.getTime()); } for (FormatAndPrecision f : FormatAndPrecision.values()) { ParsePosition pos = new ParsePosition(0); Date time = f.getFormat().parse(computedValue, pos); if (pos.getIndex() == computedValue.length()) { DateRange range = f.expand(time); if (range.getMinValue().equals(range.getMaxValue())) { return range.getMinValue(); } else { return range; } } } throw new ParseException("Invalid date: " + value, 0); } /** * 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. */ 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("Invalid period increment given: " + period, 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("Missing symbol in \"" + period + "\".", lower); } letter = period.charAt(upper); } letter = Character.toUpperCase(letter); final double value = Double.parseDouble(period.substring(lower, upper)); 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; // TODO: handle months in a better way than just taking the average length. 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; } }