
de.unkrig.commons.util.TimeTable Maven / Gradle / Ivy
/*
* de.unkrig.commons - A general-purpose Java class library
*
* Copyright (c) 2011, Arno Unkrig
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
* following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the
* following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
* following disclaimer in the documentation and/or other materials provided with the distribution.
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
* products derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package de.unkrig.commons.util;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import de.unkrig.commons.lang.ExceptionUtil;
import de.unkrig.commons.text.parser.AbstractParser;
import de.unkrig.commons.text.parser.ParseException;
import de.unkrig.commons.text.scanner.AbstractScanner;
import de.unkrig.commons.text.scanner.StatefulScanner;
/**
* Represents singular or periodic time events.
*/
public abstract
class TimeTable {
TimeTable() {}
/**
* @return The next scheduled point in time after previous, or {@link #MAX_DATE} iff there is no "next"
* execution
*/
public abstract Date
next(Date previous);
/**
* A {@link TimeTable} who's {@link #next(Date)} method will return date if previous is
* before date, and {@link #MAX_DATE} otherwise.
*/
public static TimeTable
once(final Date date) {
return new TimeTable() {
@Override public Date
next(Date previous) { return date.compareTo(previous) > 0 ? date : TimeTable.MAX_DATE; }
@Override public String
toString() { return date.toString(); }
};
}
/**
* A {@link TimeTable} who's {@link #next(Date)} method always returns {@link #MAX_DATE}.
*/
public static final TimeTable NEVER = new TimeTable() {
@Override public Date
next(Date previous) { return TimeTable.MAX_DATE; }
@Override public String
toString() { return "NEVER"; }
};
/**
* A {@link Date} very far in the future.
*/
public static final Date MAX_DATE = new Date(Long.MAX_VALUE);
/**
* A {@link Date} very far in the past.
*/
public static final Date MIN_DATE = new Date(Long.MIN_VALUE);
private enum TokenType { INTEGER, IDENTIFIER, OPERATOR, SPACE, BEFORE_TIME_PATTERN }
private enum ScannerState { IN_TIME_PATTERN }
/**
* Creates a {@link TimeTable} from a string:
*
* Example Meaning
* 2012-12-31 23:59:59
2012-12-31 23:59
2012-12-31 One-time
* *-12-31 23:59:59
*-12-31 23:59
*-12-31 Once per year
* *-*-31 23:59:59
*-*-31 23:59
*-*-31 Once per month
* Mon 23:59:59
Mon 23:59
Mon Once per week
* 23:59:59
23:59 Once per day
* *:59:59
*:59 Once per hour
* *:*:59
*:* Once per minute
* *:*:* Every second
*
* Instead of '*', integer ranges, integer lists, and combinations thereof can be given:
*
* - (1,2,3)
*
- (2020-2031)
*
- (12-3,7)
*
* Instead of 'Mon', weekday ranges, weekday lists, and combinations thereof can be given:
*
* - Sat,Sun
*
- Mon-Fri
*
- Sun-Mon
*
- Mon-Wed,Sat
*
* If both day-of-month and day-of-week are specified, then both must match; e.g. '*-2-(8-14) Mon' means
* 'second monday of february'.
*
* Multiple patterns can be specified, separated with commas, which means that the {@link #next(Date)} method
* will return the earliest next point-in-time that matches any of the patterns. Example: '*-*-1,Mon' -
* next first-of-month or next monday, whichever comes first.
*/
public static TimeTable
parse(String s) throws ParseException {
StatefulScanner scanner = (
new StatefulScanner(ScannerState.class)
);
// A look-ahead pseudo token that is produced to indicate that the following pattern is a TIME pattern.
scanner.addRule("(?=[^ ]*:)", TokenType.BEFORE_TIME_PATTERN).goTo(ScannerState.IN_TIME_PATTERN);
scanner.addRule(
"\\d+",
TokenType.INTEGER
);
scanner.addRule(
ScannerState.IN_TIME_PATTERN,
"\\d+",
TokenType.INTEGER
).goTo(ScannerState.IN_TIME_PATTERN);
scanner.addRule(
"[:\\-/\\*(),]",
TokenType.OPERATOR
);
scanner.addRule(
ScannerState.IN_TIME_PATTERN,
"[:\\-/\\*(),]",
TokenType.OPERATOR
).goTo(ScannerState.IN_TIME_PATTERN);
scanner.addRule(scanner.ANY_STATE, "\\w+", TokenType.IDENTIFIER);
scanner.addRule(scanner.ANY_STATE, " +", TokenType.SPACE);
scanner.setInput(s);
try {
return new Parser(scanner).parse();
} catch (ParseException pe) {
throw ExceptionUtil.wrap("\"" + s + "\" at offset " + scanner.getPreviousTokenOffset(), pe);
}
}
private static final
class Parser extends AbstractParser {
/**
* @param scanner Must support {@link CharStream#peek(int)} with distances greater than zero
*/
Parser(AbstractScanner scanner) {
super(scanner);
}
/**
* Parses a {@link TimeTable} object from a {@link CharStream}.
*/
public TimeTable
parse() throws ParseException {
return this.parsePatternSequence();
}
/**
*
* pattern-sequence :=
* pattern { ',' pattern }
*
*/
private TimeTable
parsePatternSequence() throws ParseException {
TimeTable tt = this.parsePattern();
while (this.peekRead(",")) {
final TimeTable lhs = tt, rhs = this.parsePattern();
tt = new TimeTable() {
@Override public Date
next(Date previous) {
Date d1 = lhs.next(previous);
Date d2 = rhs.next(previous);
return d1.compareTo(d2) < 0 ? d1 : d2;
}
@Override public String
toString() {
return lhs + "," + rhs;
}
};
}
this.eoi();
return tt;
}
/**
*
* pattern :=
* [ date-pattern ] [ day-of-week-pattern ] [ time-of-day-pattern ]
*
*/
private TimeTable
parsePattern() throws ParseException {
final IntegerPattern year, month, dayOfMonth, dayOfWeek, hour, minute, second;
PARSE: {
if (this.peek(TokenType.BEFORE_TIME_PATTERN) != null || this.peek(TokenType.IDENTIFIER) != null) {
year = (month = (dayOfMonth = IntegerPattern.ANY));
} else {
// Date pattern.
year = this.parseYearPattern();
this.read("-");
month = this.parseMonthPattern();
this.read("-");
dayOfMonth = this.parseDayOfMonthPattern();
if (this.peek() == null) {
dayOfWeek = IntegerPattern.ANY;
hour = (minute = (second = IntegerPattern.ZERO));
break PARSE;
}
this.read(TokenType.SPACE);
}
// Day-of-week.
if (this.peek(TokenType.IDENTIFIER) != null) {
dayOfWeek = this.parseDayOfWeekPattern();
if (this.peek() == null) {
hour = (minute = (second = IntegerPattern.ZERO));
break PARSE;
}
this.read(TokenType.SPACE);
} else {
dayOfWeek = IntegerPattern.ANY;
}
// Time-of-day.
this.read(TokenType.BEFORE_TIME_PATTERN);
hour = this.parseHourPattern();
this.read(":");
minute = this.parseMinutePattern();
if (this.peekRead(":")) {
second = this.parseSecondPattern();
} else {
second = IntegerPattern.ZERO;
}
}
return new TimeTable() {
@Override public Date
next(Date previous) {
Calendar cal = Calendar.getInstance();
cal.setTime(previous);
cal.add(Calendar.SECOND, 1);
{
int ss = second.getConstant();
if (ss == -1) {
while (!second.matches(cal.get(Calendar.SECOND))) cal.add(Calendar.SECOND, 1);
} else {
if (cal.get(Calendar.SECOND) > ss) cal.add(Calendar.MINUTE, 1);
cal.set(Calendar.SECOND, ss);
}
}
{
int mm = minute.getConstant();
if (mm == -1) {
while (!minute.matches(cal.get(Calendar.MINUTE))) cal.add(Calendar.MINUTE, 1);
} else {
if (cal.get(Calendar.MINUTE) > mm) cal.add(Calendar.HOUR_OF_DAY, 1);
cal.set(Calendar.MINUTE, mm);
}
}
{
int hh = hour.getConstant();
if (hh == -1) {
while (!hour.matches(cal.get(Calendar.HOUR_OF_DAY))) cal.add(Calendar.HOUR_OF_DAY, 1);
} else {
if (cal.get(Calendar.HOUR_OF_DAY) > hh) cal.add(Calendar.HOUR_OF_DAY, 1);
cal.set(Calendar.HOUR_OF_DAY, hh);
}
}
{
int yy = year.getConstant();
if (yy == -1) {
if (!year.matches(cal.get(Calendar.YEAR))) {
cal.set(Calendar.DAY_OF_MONTH, 1);
cal.set(Calendar.MONTH, Calendar.JANUARY);
do {
cal.add(Calendar.YEAR, 1);
} while (!year.matches(cal.get(Calendar.YEAR)));
}
} else {
if (cal.get(Calendar.YEAR) > yy) return TimeTable.MAX_DATE;
if (yy > cal.get(Calendar.YEAR)) {
cal.set(Calendar.DAY_OF_MONTH, 1);
cal.set(Calendar.MONTH, Calendar.JANUARY);
cal.set(Calendar.YEAR, yy);
}
}
}
{
int mo = month.getConstant();
if (mo == -1) {
if (!month.matches(cal.get(Calendar.MONTH) + 1)) {
cal.set(Calendar.DAY_OF_MONTH, 1);
do {
cal.add(Calendar.MONTH, 1);
} while (
!month.matches(cal.get(Calendar.MONTH) + 1)
|| !year.matches(cal.get(Calendar.YEAR))
);
}
} else {
if (cal.get(Calendar.MONTH) + 1 > mo) {
do {
cal.add(Calendar.YEAR, 1);
} while (!year.matches(cal.get(Calendar.YEAR)));
}
cal.set(Calendar.MONTH, mo - 1);
}
}
while (
!dayOfMonth.matches(cal.get(Calendar.DAY_OF_MONTH))
|| !dayOfWeek.matches(cal.get(Calendar.DAY_OF_WEEK))
|| !month.matches(cal.get(Calendar.MONTH) + 1)
|| !year.matches(cal.get(Calendar.YEAR))
) cal.add(Calendar.DAY_OF_MONTH, 1);
return cal.getTime();
}
@Override public String
toString() {
StringBuilder sb = new StringBuilder();
if (year != IntegerPattern.ANY || month != IntegerPattern.ANY || dayOfMonth != IntegerPattern.ANY) {
sb.append(year).append('-').append(month).append('-').append(dayOfMonth);
}
if (dayOfWeek != IntegerPattern.ANY) {
if (sb.length() > 0) sb.append(' ');
sb.append(dayOfWeek);
}
if (
sb.length() == 0
|| hour.getConstant() != 0
|| minute.getConstant() != 0
|| second.getConstant() != 0
) {
if (sb.length() > 0) sb.append(' ');
sb.append(hour).append(':').append(minute);
if (second.getConstant() != 0) sb.append(':').append(second);
}
return sb.toString();
}
};
}
/**
*
* weekday-pattern :=
* weekday-range { ',' weekday-range }
*
*/
private IntegerPattern
parseDayOfWeekPattern() throws ParseException {
IntegerPattern ip = this.parseWeekdayRange();
while (this.peekRead(",")) {
final IntegerPattern lhs = ip, rhs = this.parseWeekdayRange();
ip = new IntegerPattern() {
@Override public boolean
matches(int subject) { return lhs.matches(subject) || rhs.matches(subject); }
@Override public int
getConstant() { return -1; }
@Override public String
toString() { return lhs + "," + rhs; }
};
}
return ip;
}
/**
*
* weekday-range :=
* weekday [ '-' weekday ]
*
*/
private IntegerPattern
parseWeekdayRange() throws ParseException {
final int from = this.scanWeekday();
if (!this.peekRead("-")) return new ConstantIntegerPattern(from) {
@Override public String
toString() { return Parser.WEEKDAY_NAMES[from]; }
};
final int to = this.scanWeekday();
return new RangeIntegerPattern(from, to) {
@Override public String
toString() { return Parser.WEEKDAY_NAMES[from] + '-' + Parser.WEEKDAY_NAMES[to]; }
};
}
private IntegerPattern
parseYearPattern() throws ParseException {
return this.parseIntegerPattern(0, 3000, false);
}
private IntegerPattern
parseMonthPattern() throws ParseException {
return this.parseIntegerPattern(Calendar.JANUARY + 1, Calendar.DECEMBER + 1, false);
}
private IntegerPattern
parseDayOfMonthPattern() throws ParseException {
return this.parseIntegerPattern(1, 31, false);
}
private IntegerPattern
parseHourPattern() throws ParseException {
return this.parseIntegerPattern(0, 23, true);
}
private IntegerPattern
parseMinutePattern() throws ParseException {
return this.parseIntegerPattern(0, 59, true);
}
private IntegerPattern
parseSecondPattern() throws ParseException {
return this.parseIntegerPattern(0, 59, true);
}
/**
*
* integer-pattern :=
* '*' [ '/ integer ]
* | '(' integer-range { ',' integer-range } ')'
* | integer-range <= Iff allowUnparenthesizedRange
* | integer <= Iff !allowUnparenthesizedRange
*
*/
private IntegerPattern
parseIntegerPattern(int min, int max, boolean allowUnparenthesizedRange) throws ParseException {
// '*'
if (this.peekRead("*")) {
if (this.peekRead("/")) {
return new StepIntegerPattern(IntegerPattern.ANY, this.parseInteger(min, max));
} else {
return IntegerPattern.ANY;
}
}
// '(' integer-range { ',' integer-range } ')'
if (this.peekRead("(")) {
IntegerPattern ip = this.parseIntegerRange(min, max);
while (this.peekRead(",")) {
final IntegerPattern lhs = ip, rhs = this.parseIntegerRange(min, max);
ip = new IntegerPattern() {
@Override public boolean
matches(int subject) { return lhs.matches(subject) || rhs.matches(subject); }
@Override public int
getConstant() { return -1; }
@Override public String
toString() { return lhs + "," + rhs; }
};
}
this.read(")");
final IntegerPattern fip = ip;
return new IntegerPattern() {
@Override public boolean
matches(int subject) { return fip.matches(subject); }
@Override public int
getConstant() { return fip.getConstant(); }
@Override public String
toString() { return '(' + fip.toString() + ')'; }
};
}
// integer-range
// integer
return (
allowUnparenthesizedRange
? this.parseIntegerRange(min, max)
: new ConstantIntegerPattern(this.parseInteger(min, max))
);
}
/**
*
* integer-range :=
* integer [ '-' integer [ '/' integer ] ]
*
*/
private IntegerPattern
parseIntegerRange(int min, int max) throws ParseException {
int from = this.parseInteger(min, max);
if (this.peekRead("-")) {
int to = this.parseInteger(min, max);
IntegerPattern result = new RangeIntegerPattern(from, to);
if (this.peekRead("/")) {
result = new StepIntegerPattern(result, this.parseInteger(min, max));
}
return result;
}
return new ConstantIntegerPattern(from);
}
/**
* @throws ParseException Iff not '{@code min <= x <= max}'
*/
private int
parseInteger(int min, int max) throws ParseException {
int result = this.scanInteger();
if (result < min) {
throw new ParseException("Value '" + result + "' is too small - must be '" + min + "' or greater");
}
if (result > max) {
throw new ParseException("Value '" + result + "' is too large - mus be '" + max + "' or less");
}
return result;
}
private int
scanInteger() throws ParseException {
return Integer.parseInt(this.read(TokenType.INTEGER));
}
/**
*
* weekday :=
* 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun'
*/
private int
scanWeekday() throws ParseException {
String word = this.read(TokenType.IDENTIFIER);
Integer wd = Parser.WEEKDAY_DISPLAY_NAMES.get(word);
if (wd == null) {
throw new ParseException(
"Invalid weekday '"
+ word
+ "' - valid weekdays are "
+ Parser.WEEKDAY_DISPLAY_NAMES
);
}
return wd;
}
// 'Calendar.getInstance().getDisplayNames(DAY_OF_WEEK, Calendar.ALL_STYLES, Locale.US)' would be nice to use,
// but is only available in JRE 1.6+.
private static final Map WEEKDAY_DISPLAY_NAMES;
static {
Map m = new HashMap();
m.put("Sun", Calendar.SUNDAY);
m.put("Mon", Calendar.MONDAY);
m.put("Tue", Calendar.TUESDAY);
m.put("Wed", Calendar.WEDNESDAY);
m.put("Thu", Calendar.THURSDAY);
m.put("Fri", Calendar.FRIDAY);
m.put("Sat", Calendar.SATURDAY);
WEEKDAY_DISPLAY_NAMES = Collections.unmodifiableMap(m);
}
protected static final String[] WEEKDAY_NAMES = { null, "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" };
interface IntegerPattern {
boolean
matches(int subject);
int
getConstant();
IntegerPattern ANY = new IntegerPattern() {
@Override public boolean
matches(int subject) { return true; }
@Override public int
getConstant() { return -1; }
@Override public String
toString() { return "*"; }
};
IntegerPattern ZERO = new ConstantIntegerPattern(0);
}
static
class ConstantIntegerPattern implements IntegerPattern {
private final int constantValue;
ConstantIntegerPattern(int constantValue) { this.constantValue = constantValue; }
@Override public boolean
matches(int subject) { return subject == this.constantValue; }
@Override public int
getConstant() { return this.constantValue; }
@Override public String
toString() { return String.valueOf(this.constantValue); }
}
static
class RangeIntegerPattern implements IntegerPattern {
private final int from, to;
RangeIntegerPattern(int from, int to) {
this.from = from;
this.to = to;
}
@Override public boolean
matches(int subject) {
return (
this.from <= this.to
? subject >= this.from && subject <= this.to
: subject >= this.to || subject <= this.from
);
}
@Override public int
getConstant() { return -1; }
@Override public String
toString() { return this.from + "-" + this.to; }
}
static
class StepIntegerPattern implements IntegerPattern {
private final IntegerPattern delegate;
private final int step;
StepIntegerPattern(IntegerPattern delegate, int step) {
this.delegate = delegate;
this.step = step;
}
@Override public boolean
matches(int subject) { return this.delegate.matches(subject) && subject % this.step == 0; }
@Override public int
getConstant() { return -1; }
@Override public String
toString() { return this.delegate + "/" + this.step; }
}
}
@Override public abstract String
toString();
}