/*
* Weblounge: Web Content Management System
* Copyright (c) 2003 - 2011 The Weblounge Team
* http://entwinemedia.com/weblounge
*
* This program 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; either version 2
* of the License, or (at your option) any later version.
*
* This program 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.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package ch.entwine.weblounge.common.impl.scheduler;
import ch.entwine.weblounge.common.scheduler.JobTrigger;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.StringTokenizer;
/**
* Job trigger that is created from a cron-style configuration.
*
* <pre>
* .---------------- minute (0 - 59)
* | .------------- hour (0 - 23)
* | | .---------- day of month (1 - 31)
* | | | .------- month (1 - 12) OR jan,feb,mar,apr ...
* | | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
* | | | | |
* * * * * *
* </pre>
* <p>
* This trigger also supports the following special values as the scheduling
* expression:
* <ul>
* <li>@restart - fires once after scheduling</li>
* <li>@hourly - fires on the first minute of the hour</li>
* <li>@daily - fires once per day, at midnight</li>
* <li>@midnight - same as @daily</li>
* <li>@weekly - fires once a week, at midnight on Sundays</li>
* <li>@monthly - fires once at midnight on the first of every month</li>
* <li>@yearly - fires once per year, at midnight on the first of January</li>
* <li>@annually - same as @yearly</li>
* </ul>
*/
public final class CronJobTrigger implements JobTrigger {
/** The month names */
private static final String[] MONTHS = new String[] {
"jan",
"feb",
"mar",
"apr",
"may",
"jun",
"jul",
"aug",
"sep",
"oct",
"nov",
"dec" };
/** The day names */
private static final String[] DAYS = new String[] {
"sun",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun" };
/** All hours of a day */
private static final int[] ALL_HOURS = new int[] {
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23 };
/** All days of a month */
private static final int[] ALL_DAYS_OF_MONTH = new int[] {
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31 };
/** All months of a year */
private static final int[] ALL_MONTHS = new int[] {
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12 };
/** All days of a week */
private static final int[] ALL_DAYS_OF_WEEK = new int[] { 0, 1, 2, 3, 4, 5, 6 };
/** The minutes on which to execute the job */
private int[] minutes;
/** The hours on which to execute the job */
private int[] hours;
/** The month on which to execute the job */
private int[] months;
/** The days of month on which to execute the job */
private int[] daysOfMonth;
/** The days of week on which to execute the job */
private int[] daysOfWeek;
/** True if this job should be run once */
private boolean once = false;
/** The cached execution time */
private long nextExecution = -1;
/** The last execution */
private long lastExecution = -1;
/**
* Creates a new trigger, that will - without further configuration - never
* fire.
*/
public CronJobTrigger() {
init();
}
/**
* Creates a new cron job. The next execution is calculated from
* <code>entry</code> which specifies a crontab entry.
*
* @param entry
* the crontab entry
* @throws IllegalArgumentException
* if <code>entry</code> is not a proper crontab entry
*/
public CronJobTrigger(String entry) throws IllegalArgumentException {
try {
init();
parse(entry);
} catch (Throwable t) {
throw new IllegalArgumentException("Cron schedule " + entry + " is malformed: " + t.getMessage(), t);
}
}
/**
* {@inheritDoc}
*
* @see java.lang.Object#clone()
*/
@Override
public Object clone() throws CloneNotSupportedException {
return super.clone();
}
/**
* Resets this trigger's memory, which is equal to setting its last execution
* date to <code>-1</code>.
*/
public void reset() {
lastExecution = -1;
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.scheduler.JobTrigger#getNextExecutionAfter(Date)
*/
public Date getNextExecutionAfter(Date date) {
if (date == null)
return null;
if (nextExecution > date.getTime())
return new Date(nextExecution);
else if (once) {
if (lastExecution > -1)
return null;
lastExecution = date.getTime();
return date;
}
Calendar c = Calendar.getInstance();
c.setFirstDayOfWeek(Calendar.SUNDAY);
c.setTime(date);
// Move to next full minute
c.set(Calendar.MILLISECOND, 0);
c.add(Calendar.SECOND, 60 - c.get(Calendar.SECOND));
// We are looking for the next possibility *after* date
if (c.getTime().equals(date))
c.add(Calendar.MINUTE, 1);
// minutes
while (!matches(c, Calendar.MINUTE, minutes)) {
c.add(Calendar.MINUTE, 1);
}
// hours
while (!matches(c, Calendar.HOUR_OF_DAY, hours)) {
c.add(Calendar.HOUR_OF_DAY, 1);
}
// days and weekdays
while (!matches(c, daysOfMonth, daysOfWeek)) {
c.add(Calendar.DAY_OF_MONTH, 1);
}
// months
while (!matches(c, Calendar.MONTH, months)) {
c.add(Calendar.MONTH, 1);
c.set(Calendar.DAY_OF_MONTH, 1);
}
// re-adjust days and weekdays after month has changed
while (!matches(c, daysOfMonth, daysOfWeek)) {
c.add(Calendar.DAY_OF_MONTH, 1);
}
// Cache the new value
return new Date(c.getTimeInMillis());
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.scheduler.JobTrigger#triggered(java.util.Date)
*/
public void triggered(Date date) {
// We don't care too much...
}
/**
* Initializes this cron job. By default, the job will never be executed.
*/
private void init() {
setMinutes(new int[] {});
setHours(new int[] {});
setDaysOfMonth(new int[] {});
setMonths(new int[] {});
setDaysOfWeek(new int[] {});
}
/**
* Returns the minutes on which to execute the job.
*
* @return the minutes
*/
public int[] getMinutes() {
return minutes;
}
/**
* Sets the minutes on which to execute the job.
*
* @param minutes
* the minutes
*/
public void setMinutes(int[] minutes) {
if (minutes == null)
minutes = new int[] {};
this.minutes = minutes;
}
/**
* Sets the minutes on which to execute the job.
*
* @param minutes
* the minutes
*/
public void setMinutes(String minutes) {
this.minutes = parseMinutes(minutes);
}
/**
* Returns the hours on which to execute the job.
*
* @return the hours
*/
public int[] getHours() {
return hours;
}
/**
* Sets the hours on which to execute the job.
*
* @param hours
* the hours
*/
public void setHours(int[] hours) {
if (hours == null)
hours = new int[] {};
this.hours = hours;
}
/**
* Sets the hours on which to execute the job.
*
* @param hours
* the hours
*/
public void setHours(String hours) {
this.hours = parseHours(hours);
}
/**
* Returns the months on which to execute the job.
*
* @return the months
*/
public int[] getMonths() {
return months;
}
/**
* Sets the months on which to execute the job.
*
* @param months
* the months
*/
public void setMonths(int[] months) {
if (months == null)
months = new int[] {};
this.months = months;
}
/**
* Sets the months on which to execute the job.
*
* @param months
* the months
*/
public void setMonths(String months) {
this.months = parseMonths(months);
}
/**
* Returns the days of month on which to execute the job.
*
* @return the days of month
*/
public int[] getDaysOfMonth() {
return daysOfMonth;
}
/**
* Sets the days of month on which to execute the job.
*
* @param daysOfMonth
* the days of month
*/
public void setDaysOfMonth(int[] daysOfMonth) {
if (daysOfMonth == null)
daysOfMonth = new int[] {};
this.daysOfMonth = daysOfMonth;
}
/**
* Sets the days of month on which to execute the job.
*
* @param daysOfMonth
* the days of month
*/
public void setDaysOfMonth(String daysOfMonth) {
this.daysOfMonth = parseDaysOfMonth(daysOfMonth);
}
/**
* Returns the days of week on which to execute the job.
*
* @return the days of week
*/
public int[] getDaysOfWeek() {
return daysOfWeek;
}
/**
* Sets the days of week on which to execute the job.
*
* @param daysOfWeek
* the days of week
*/
public void setDaysOfWeek(int[] daysOfWeek) {
if (daysOfWeek == null)
daysOfWeek = new int[] {};
this.daysOfWeek = daysOfWeek;
}
/**
* Sets the days of week on which to execute the job.
*
* @param daysOfWeek
* the days of week
*/
public void setDaysOfWeek(String daysOfWeek) {
this.daysOfWeek = parseDaysOfWeek(daysOfWeek);
}
/**
* Returns <code>true</code> if <code>field</code> specifies a field in the
* calendar that matches an entry of the reference.
*
* @param c
* the calendar
* @param field
* the calendar field
* @param reference
* the reference values
* @return <code>true</code> if the calendar matches
*/
private static boolean matches(Calendar c, int field, int[] reference) {
if (reference.length == 0)
return true;
int offset = 0;
switch (field) {
case Calendar.MONTH:
offset = 1;
break;
case Calendar.DAY_OF_WEEK:
offset = -1;
break;
default:
offset = 0;
}
for (int value : reference)
if (value == c.get(field) + offset)
return true;
return false;
}
/**
* Returns <code>true</code> if <code>field</code> specifies a field in the
* calendar that matches an entry of the reference.
*
* @param c
* the calendar
* @param reference
* the reference values
* @return <code>true</code> if the calendar matches
*/
private static boolean matches(Calendar c, int[] days, int[] weekdays) {
if (days.length == 0)
return matches(c, Calendar.DAY_OF_WEEK, weekdays);
else if (weekdays.length == 0)
return matches(c, Calendar.DAY_OF_MONTH, days);
else
return matches(c, Calendar.DAY_OF_MONTH, days) && matches(c, Calendar.DAY_OF_WEEK, weekdays);
}
/**
* Parses the given configuration string and tries to extract the execution
* information for minute, hour, day of month, month and day of week.
*
* @param str
* the configuration string
* @throw IllegalArgumentException if <code>str</code> is either empty or
* malformed
*/
private void parse(String str) {
if (str == null) {
throw new IllegalArgumentException("Empty job execution instruction found!");
}
String[] parts = str.split(" ");
if (parts.length == 1) {
parseSpecial(str);
} else if (parts.length == 5) {
try {
setMinutes(parseMinutes(parts[0].trim().toLowerCase()));
setHours(parseHours(parts[1].trim().toLowerCase()));
setDaysOfMonth(parseDaysOfMonth(parts[2].trim().toLowerCase()));
setMonths(parseMonths(parts[3].trim().toLowerCase()));
setDaysOfWeek(parseDaysOfWeek(parts[4].trim().toLowerCase()));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Schedule " + str + " is malformed:" + e.getMessage());
}
} else {
throw new IllegalArgumentException("Schedule " + str + " is malformed!");
}
}
/**
* Parses the minutes field. The field may only contain integer numbers
* between 0 and 59.
*
* @param str
* the field
* @return the minutes
*/
private int[] parseMinutes(String str) {
try {
return enumerate(str, 0, 59);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Error parsing minutes: " + e.getMessage());
}
}
/**
* Parses the hours field. The field may only contain integer numbers between
* 0 and 23.
*
* @param str
* the field
* @return the hours
*/
private int[] parseHours(String str) {
try {
return enumerate(str, 0, 23);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Error parsing hours: " + e.getMessage());
}
}
/**
* Parses the months field. The field may contain integer numbers between 1
* and 12 or the first three letters of the english month names (jan, feb
* etc.).
*
* @param str
* the field
* @return the months
*/
private int[] parseMonths(String str) {
try {
return enumerate(str, 1, 12);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Error parsing months: " + e.getMessage());
}
}
/**
* Parses the day of month field. The field may contain only integer numbers
* between 1 and 31.
*
* @param str
* the field
* @return the days
*/
private int[] parseDaysOfMonth(String str) {
try {
return enumerate(str, 1, 31);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Error parsing days of month: " + e.getMessage());
}
}
/**
* Parses the day of month field. The field may contain integer numbers
* between 0 and 7 or the first three letters of the english day names (mon,
* tue etc.). <br>
* Note that 0 and 7 both mean sunday.
*
* @param str
* the field
* @return the weekdays
*/
private int[] parseDaysOfWeek(String str) {
try {
return enumerate(str.replaceAll("7", "0"), 0, 6);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Error parsing days of week: " + e.getMessage());
}
}
/**
* Parses the string for special commands like "daily", "weekly" etc. Valid
* identifiers are:
*
* @restart Run once, at startup.
* @yearly Run once a year, "0 0 1 1 *".
* @annually (same as @yearly)
* @monthly Run once a month, "0 0 1 * *".
* @weekly Run once a week, "0 0 * * 0".
* @daily Run once a day, "0 0 * * *".
* @midnight (same as @daily)
* @hourly Run once an hour, "0 * * * *".
*
* @param str
* @return
*/
private void parseSpecial(String str) {
if (str == null)
return;
str = str.trim().toLowerCase();
if ("@restart".equals(str)) {
once = true;
} else if ("@yearly".equals(str) || "@annually".equals(str)) {
setMinutes(new int[] { 0 });
setHours(new int[] { 0 });
setDaysOfMonth(new int[] { 1 });
setMonths(new int[] { 1 });
setDaysOfWeek(ALL_DAYS_OF_WEEK);
} else if ("@monthly".equals(str)) {
setMinutes(new int[] { 0 });
setHours(new int[] { 0 });
setDaysOfMonth(new int[] { 1 });
setMonths(ALL_MONTHS);
setDaysOfWeek(ALL_DAYS_OF_WEEK);
} else if ("@weekly".equals(str)) {
setMinutes(new int[] { 0 });
setHours(new int[] { 0 });
setDaysOfMonth(ALL_DAYS_OF_MONTH);
setMonths(ALL_MONTHS);
setDaysOfWeek(new int[] { 0 });
} else if ("@daily".equals(str) || "@midnight".equals(str)) {
setMinutes(new int[] { 0 });
setHours(new int[] { 0 });
setDaysOfMonth(ALL_DAYS_OF_MONTH);
setMonths(ALL_MONTHS);
setDaysOfWeek(ALL_DAYS_OF_WEEK);
} else if ("@hourly".equals(str)) {
setMinutes(new int[] { 0 });
setHours(ALL_HOURS);
setDaysOfMonth(ALL_DAYS_OF_MONTH);
setMonths(ALL_MONTHS);
setDaysOfWeek(ALL_DAYS_OF_WEEK);
} else {
throw new IllegalArgumentException("Special value " + str + " is unknown");
}
}
/**
* Takes a field part (that is, field entries split by ",") and extracts the
* int values.
*/
private static int[] enumerate(String in, int min, int max) {
List<Integer> result = new ArrayList<Integer>();
// a, b, c
StringTokenizer tok = new StringTokenizer(in.trim(), ",");
while (tok.hasMoreTokens()) {
String str = tok.nextToken().trim();
int stepsize = 1;
int start = min;
int end = max;
// Extract the step size, */2 or 7-10/2
int stepdivider = str.indexOf('/');
if (stepdivider > 0) {
try {
stepsize = Integer.parseInt(str.substring(stepdivider + 1));
if (!"*".equals(str.substring(0, str.indexOf('/'))))
throw new IllegalArgumentException("Malformed stepsize expression '" + str + "', first argument should be *");
for (int i = 0; i < max; i++) {
if (i % stepsize == 0)
result.add(i);
}
return toIntArray(result);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Illegal stepsize in '" + str + "'");
}
}
// *, 7-12, 13
int hyphen = str.indexOf('-');
if (str.startsWith("*")) {
start = min;
end = max;
} else if (hyphen > 0) {
try {
start = Integer.parseInt(toNumber(str.substring(0, hyphen)));
end = Integer.parseInt(toNumber(str.substring(hyphen + 1)));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid interval in '" + str + "'");
}
} else {
try {
start = Integer.parseInt(toNumber(str));
end = start;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid number: '" + str + "'");
}
}
// Check for minimum / maximum
if (start < min)
throw new IllegalArgumentException("Value " + start + " in '" + in + "' is smaller than the minimum of " + min);
if (end > max)
throw new IllegalArgumentException("Value " + end + " in '" + in + "' is larger than the maximum of " + min);
// Return the result
for (int i = start; i <= end; i += stepsize) {
result.add(new Integer(i));
}
}
return toIntArray(result);
}
/**
* Replaces day and month names with their corresponding value.
*
* @param in
* the incoming string, may contain names or numbers
* @return the number
*/
private static String toNumber(String in) {
in = in.toLowerCase();
for (int i = 0; i < MONTHS.length; i++)
if (MONTHS[i].equals(in))
return Integer.toString(i + 1);
for (int i = 0; i < DAYS.length; i++)
if (DAYS[i].equals(in))
return Integer.toString(i);
try {
Integer.parseInt(in);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("'" + in + "' is not a number!");
}
return in;
}
/**
* Returns an array of <code>int</code> values that are contained in the
* <code>shorts</code> list.
*
* @param shorts
* the list of <code>int</code> values
* @return an array of <code>int</code> values.
*/
private static int[] toIntArray(List<Integer> shorts) {
int[] array = new int[shorts.size()];
for (int i = 0; i < shorts.size(); i++)
array[i] = shorts.get(i).shortValue();
return array;
}
/**
* Returns a cron-style expression of this trigger.
*
* @return the expression
*/
public Object getCronExpression() {
StringBuffer buf = new StringBuffer();
// minutes
if (minutes.length < 60) {
for (int i = 0; i < minutes.length; i++) {
if (i > 0)
buf.append(",");
buf.append(minutes[i]);
}
buf.append(" ");
} else {
buf.append("* ");
}
// hours
if (hours.length < 24) {
for (int i = 0; i < hours.length; i++) {
if (i > 0)
buf.append(",");
buf.append(hours[i]);
}
buf.append(" ");
} else {
buf.append("* ");
}
// days of month
if (daysOfMonth.length < 31) {
for (int i = 0; i < daysOfMonth.length; i++) {
if (i > 0)
buf.append(",");
buf.append(daysOfMonth[i]);
}
buf.append(" ");
} else {
buf.append("* ");
}
// months
if (months.length < 12) {
for (int i = 0; i < months.length; i++) {
if (i > 0)
buf.append(",");
buf.append(months[i]);
}
buf.append(" ");
} else {
buf.append("* ");
}
// days of week
if (daysOfWeek.length < 7) {
for (int i = 0; i < daysOfWeek.length; i++) {
if (i > 0)
buf.append(",");
buf.append(daysOfWeek[i]);
}
} else {
buf.append("*");
}
return buf.toString();
}
/**
* {@inheritDoc}
*
* @see java.lang.Object#hashCode()
*/
@Override
public int hashCode() {
return super.hashCode();
}
/**
* {@inheritDoc}
*
* @see java.lang.Object#equals(java.lang.Object)
*/
@Override
public boolean equals(Object obj) {
if (obj instanceof CronJobTrigger) {
CronJobTrigger trigger = (CronJobTrigger) obj;
return getCronExpression().equals(trigger.getCronExpression());
}
return false;
}
/**
* {@inheritDoc}
*
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
StringBuffer buf = new StringBuffer("Cron job trigger");
buf.append(" [");
boolean detailsAdded = false;
// minutes
if (minutes.length > 0) {
if (detailsAdded)
buf.append(";");
buf.append("minute=");
for (int i = 0; i < minutes.length; i++) {
if (i > 0)
buf.append(",");
if (minutes[i] == -1)
buf.append("*");
else
buf.append(minutes[i]);
}
detailsAdded = true;
}
// hours
if (hours.length > 0) {
if (detailsAdded)
buf.append(";");
buf.append("hour=");
for (int i = 0; i < hours.length; i++) {
if (i > 0)
buf.append(",");
if (hours[i] == -1)
buf.append("*");
else
buf.append(hours[i]);
}
detailsAdded = true;
}
// days of month
if (daysOfMonth.length > 0) {
if (detailsAdded)
buf.append(";");
buf.append("day-of-month=");
for (int i = 0; i < daysOfMonth.length; i++) {
if (i > 0)
buf.append(",");
if (daysOfMonth[i] == -1)
buf.append("*");
else
buf.append(daysOfMonth[i]);
}
detailsAdded = true;
}
// months
if (months.length > 0) {
if (detailsAdded)
buf.append(";");
buf.append("months=");
for (int i = 0; i < months.length; i++) {
if (i > 0)
buf.append(",");
if (months[i] == -1)
buf.append("*");
else
buf.append(CronJobTrigger.MONTHS[months[i]]);
}
detailsAdded = true;
}
// days of week
if (daysOfWeek.length > 0) {
if (detailsAdded)
buf.append(";");
buf.append("day-of-week=");
for (int i = 0; i < daysOfWeek.length; i++) {
if (i > 0)
buf.append(",");
if (daysOfWeek[i] == -1)
buf.append("*");
else
buf.append(DAYS[daysOfWeek[i]]);
}
detailsAdded = true;
}
if (!detailsAdded)
buf.append("never");
buf.append("]");
return buf.toString();
}
}