<?php
/*
* File: Cron.php
*
* Copyright (c) 2010 by Daniel Kraft <dk@d9t.de>
*
* GNU General Public License (GPL)
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU 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 General Public License for more details.
*
* You should have received a copy of the GNU 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.
*
*
* If you find bugs, please feel free to report them by mail or on
* irc: freenode://#d9t
*
*/
class CronException extends Exception {
}
class Cron {
/*
* Class to parse a single crontab entry and tell, if it matches a given timestamp.
* PLEASE NOTE: It requires a timestamp object - not just an integer.
*/
var $cron_entry;
var $timestampObj;
var $minuteField;
var $hourField;
var $domField;
var $monthField;
var $dowField;
function __construct($cron_entry, $timestampObj=null) {
/*
* constructor method
*/
$this->cron_entry = $cron_entry;
if ($timestampObj == null) $timestampObj = new Timestamp(time());
$this->timestampObj = $timestampObj;
$this->loadFields();
}
function __toString() {
return sprintf("<Cron Fields: (%s %s %s %s %s)>", $this->minuteField, $this->hourField, $this->domField, $this->monthField, $this->dowField);
}
function splitFields($fields) {
/*
* Split fields by tab+ or space+ with limit 5 (like cron wants it)
*/
return preg_split('#(\t+|\s+)#', $fields,5);
}
function loadFields() {
$fields = $this->splitFields($this->cron_entry);
$this->minuteField = new MinuteField($fields[0]);
$this->hourField = new HourField($fields[1]);
$this->domField = new DomField($fields[2]);
$this->monthField = new MonthField($fields[3]);
$this->dowField = new DowField($fields[4]);
}
function hit() {
/*
* Checks, if $this->timestampObj is a hit for the cron_entry.
*/
return $this->minuteField->hit($this->timestampObj)
&& $this->hourField->hit($this->timestampObj)
&& $this->domField->hit($this->timestampObj)
&& $this->monthField->hit($this->timestampObj)
&& $this->dowField->hit($this->timestampObj);
}
function jumpToTime($timestampObj) {
/*
* This accepts a timestampObj and sets the current entry to this time.
* Do this if you want to test a specific time for a hit.
*/
$this->timestampObj = $timestampObj;
}
function moveInTime($deltaTimestampObj) {
$deltaTimestampObj->applyToTimestamp($this->timestampObj);
}
function _moveInTime($seconds) {
$this->timestampObj->_moveInTime($seconds);
}
function getTimestamp() {
return $this->timestampObj;
}
function debug() {
/*
* call debug on each field.
*/
print "\nChecking Date: ".$this->timestampObj->rfc2822()."\n========================================\n\n";
$this->minuteField->debug($this->timestampObj);
$this->hourField->debug($this->timestampObj);
$this->domField->debug($this->timestampObj);
$this->monthField->debug($this->timestampObj);
$this->dowField->debug($this->timestampObj);
print "\n----\n";
print "Sum hit: ".$this->hit()."\n";
}
}
class Searcher {
/*
* This is a base class for all Searcher Classes.
* Searcher Classes are a implementation of a searching algorithm
* which tries to find the closest previous or next hit for a
* Cron.
*
* The methods must return a Timestamp object.
*/
var $cron;
function __construct($cron) {
$this->cron= $cron;
}
function previous() {
// dummy implementation
return $this->cron->getTimestamp();
}
function next() {
// dummy implementation
return $this->cron->getTimestamp();
}
}
class DumbIterateSearcher extends Searcher {
/*
* This searcher simply checks from now on every minute
* forward/backwards in time for a hit and returns the closest match.
*
* Stupid, simple, but correct.
* Use this only as reference.
*/
function __construct($cron) {
parent::__construct($cron);
}
function _iterate($deltaTimestampObj) {
$maxIterations = 4207550;
$currentIteration = 0;
while (!$this->cron->hit() && $maxIterations>$currentIteration) {
$deltaTimestampObj->applyToTimestamp($this->cron->timestampObj);
$currentIteration++;
}
$this->cron->timestampObj->zeroSeconds();
return $this->cron->getTimestamp();
}
function previous() {
$deltaTimestampObj = new DeltaTimestampObj();
$deltaTimestampObj->minutes(-1);
$timestampObj = $this->_iterate($deltaTimestampObj);
return $timestampObj;
}
function next() {
$deltaTimestampObj = new DeltaTimestampObj();
$deltaTimestampObj->minutes(1);
$timestampObj = $this->_iterate($deltaTimestampObj);
return $timestampObj;
}
}
class EliminateSearcher extends Searcher {
/*
* This Searcher checks for each field, if it hits.
* If not, it asks this field for the amount of time we could
* add/substract until it possibly would hit the next time.
*
* This looks very performant. Even in cases like this:
* 0 0 29 2 3
*/
function __construct($cron) {
parent::__construct($cron);
}
function _iterate() {
/*
* Idea: Maybe this could perform a little better, if we tried the
* algo always from start when successfully applying a delta to
* anything except month. Because a delta so big until it hits the
* next superior boundery is possible.
* But this becomes academical here. My worst cpu-time for this
* was below 0.25s when hopping over 20 years.
*/
for ($i=0; $i<100000; $i++) {
while (!$this->cron->monthField->hit($this->cron->timestampObj))
$this->cron->monthField->timespan->applyToTimestamp($this->cron->timestampObj);
while (!$this->cron->domField->hit($this->cron->timestampObj))
$this->cron->domField->timespan->applyToTimestamp($this->cron->timestampObj);
while (!$this->cron->dowField->hit($this->cron->timestampObj))
$this->cron->dowField->timespan->applyToTimestamp($this->cron->timestampObj);
while (!$this->cron->hourField->hit($this->cron->timestampObj))
$this->cron->hourField->timespan->applyToTimestamp($this->cron->timestampObj);
while (!$this->cron->minuteField->hit($this->cron->timestampObj))
$this->cron->minuteField->timespan->applyToTimestamp($this->cron->timestampObj);
if ($this->cron->hit()) return;
}
throw new CronException("No hit was found. Max iterations reached.");
}
function previous() {
$this->cron->minuteField->timespan->negative();
$this->cron->hourField->timespan->negative();
$this->cron->domField->timespan->negative();
$this->cron->monthField->timespan->negative();
$this->cron->dowField->timespan->negative();
$this->_iterate();
// wipe the seconds to make it reproducable.
$this->cron->timestampObj->zeroSeconds();
return $this->cron->timestampObj;
}
function next() {
$this->cron->minuteField->timespan->positive();
$this->cron->hourField->timespan->positive();
$this->cron->domField->timespan->positive();
$this->cron->monthField->timespan->positive();
$this->cron->dowField->timespan->positive();
$this->_iterate();
// wipe the seconds to make it reproducable.
$this->cron->timestampObj->zeroSeconds();
return $this->cron->timestampObj;
}
}
class Timestamp {
/*
* This class is mainly a wrapper for a integer-timestamp with
* some methods using the date() function. It's more convenient
* and nicer to read if we use that class.
*/
var $stamp;
function __construct($stamp) {
/*
* recieves a integer timestamp in unix-format (seconds since 1970 or so).
*/
$this->stamp = $stamp;
}
function __toString() {
return $this->rfc2822();
}
function copy() {
return new Timestamp($this->stamp);
}
function _date($str) {
return intval(date($str, $this->stamp));
}
function day() {
return $this->_date('j');
}
function hour() {
return $this->_date('G');
}
function minute() {
return $this->_date('i');
}
function second() {
return $this->_date('s');
}
function dom() {
return $this->_date('j');
}
function month() {
return $this->_date('n');
}
function dow() {
return $this->_date('w');
}
function daysInMonth() {
return $this->_date('t');
}
function zeroSeconds() {
/*
* removes the second-information of the timestamp.
*/
$this->stamp -= $this->second();
}
function _moveInTime($seconds) {
$this->stamp += $seconds;
}
function rfc2822() {
return date('r', $this->stamp);
}
function sqlDatetime() {
return date('Y-m-d H:i:s', $this->stamp);
}
function diffToGmt() {
/*
* Returns the number of minutes of delta to gmt.
*/
$o = date('O', $this->stamp);
$sign = substr($o,0,1);
$hours = intval($sign.substr($o,1,2));
$minutes = intval($sign.substr($o,3,2));
$diff = $hours*60+$minutes;
return $diff;
}
}
class DeltaTimestampObj {
/*
* This class represents a delta timstamp which can be positive
* or negative.
* You can set the delta convenient with several methods and after
* you're done, apply the change to your favourite Timestamp object.
*
* This method returns the latest possible value for negative
* calls and the earliest possible value for positive calls.
*/
var $delta = array('seconds'=>0, 'minutes'=>0, 'hours'=>0, 'days'=>0, 'weeks'=>0, 'months'=>0);
var $applyTimeZone = true;
function __construct($months=0, $days=0, $hours=0, $minutes=0, $seconds=0) {
$this->delta["months"] = $months;
$this->delta["days"] = $days;
$this->delta["hours"] = $hours;
$this->delta["minutes"] = $minutes;
$this->delta["seconds"] = $seconds;
}
function __toString() {
return sprintf("%s months %s days %s hours %s minutes %s seconds", $this->delta["months"], $this->delta["days"], $this->delta["hours"], $this->delta["minutes"], $this->delta["seconds"]);
}
function negative() {
/*
* changes the sign of all values to negative.
*/
foreach (array_keys($this->delta) as $index)
if ($this->delta[$index]>0) $this->delta[$index] = -$this->delta[$index];
}
function positive() {
/*
* changes the sign of all values to positive.
*/
foreach (array_keys($this->delta) as $index)
if ($this->delta[$index]<0) $this->delta[$index] = -$this->delta[$index];
}
function applyToTimestamp(&$timestampObj) {
/*
* apply the changes in the following order:
* months, days, hours, minutes, seconds
*
* This is no generic method. It's optimized for cron as it
* returns always the first possible second or the last possible
* second for a question and not the correct delta to a given
* timestamp.
*/
/*
printf ("Applying: %6dm %6dd %6dh %6dm %6ds\n",
$this->delta["months"],
$this->delta["days"],
$this->delta["hours"],
$this->delta["minutes"],
$this->delta["seconds"]);
*/
if ($this->delta["months"] < 0) {
for ($i=0; $i>$this->delta["months"]; $i--) {
$days = -$timestampObj->day();
$dt = new DeltaTimestampObj(0,$days,0,0,0);
$dt->applyToTimestamp($timestampObj);
}
}
if ($this->delta["months"] > 0) {
for ($i=0; $i<$this->delta["months"]; $i++) {
$days = $timestampObj->daysInMonth() - $timestampObj->day() + 1;
$dt = new DeltaTimestampObj(0,$days,0,0,0);
$dt->applyToTimestamp($timestampObj);
}
}
if ($this->delta["days"] < 0) {
$hours = $this->delta["days"]*24 + 23 - $timestampObj->hour();
$dt = new DeltaTimestampObj(0,0,$hours,0,0);
$dt->applyToTimestamp($timestampObj);
}
if ($this->delta["days"] > 0) {
$hours = $this->delta["days"]*24 - $timestampObj->hour();
$dt = new DeltaTimestampObj(0,0,$hours,0,0);
$dt->applyToTimestamp($timestampObj);
}
if ($this->delta["hours"] < 0) {
$minutes = $this->delta["hours"]*60 + 59 - $timestampObj->minute();
$dt = new DeltaTimestampObj(0,0,0,$minutes,0);
$dt->applyToTimestamp($timestampObj);
}
if ($this->delta["hours"] > 0) {
$minutes = $this->delta["hours"]*60 - $timestampObj->minute();
$dt = new DeltaTimestampObj(0,0,0,$minutes,0);
$dt->applyToTimestamp($timestampObj);
}
if ($this->delta["minutes"] < 0) {
$diffToGmtBefore = $timestampObj->diffToGmt();
$seconds = $this->delta["minutes"]*60 + 59 - $timestampObj->second();
$timestampObj->_moveInTime($seconds);
$diffToGmtAfter = $timestampObj->diffToGmt();
$this->applyTimeZone($timestampObj, $diffToGmtBefore, $diffToGmtAfter);
}
if ($this->delta["minutes"] > 0) {
$diffToGmtBefore = $timestampObj->diffToGmt();
$seconds = $this->delta["minutes"]*60 - $timestampObj->second();
$timestampObj->_moveInTime($seconds);
$diffToGmtAfter = $timestampObj->diffToGmt();
$this->applyTimeZone($timestampObj, $diffToGmtBefore, $diffToGmtAfter);
}
$timestampObj->_moveInTime($this->delta["seconds"]);
}
function applyTimeZone(&$timestampObj, $diffToGmtBefore, $diffToGmtAfter) {
// "correct" timezone changes (like summer/wintertime)
if (!$this->applyTimeZone) return;
$deltaMinutes = $diffToGmtBefore - $diffToGmtAfter;
if ($deltaMinutes)
$timestampObj->_moveInTime($deltaMinutes*60);
}
function seconds($val) {
$this->delta["seconds"] += $val;
}
function minutes($val) {
$this->delta["minutes"] += $val;
}
function hours($val) {
$this->delta["hours"] += $val;
}
function days($val) {
$this->delta["days"] += $val;
}
function weeks($val) {
$this->delta["weeks"] += $val;
}
function months($val) {
$this->delta["months"] += $val;
}
}
class CronField {
/*
* Class to parse a cron field. They can contain ranges
* or comma seperated lists of numbers and ranges.
* From the crontab (5) manpage:
*
* A field may be an asterisk (*), which always stands for ‘‘first-last’’.
* Ranges of numbers are allowed. Ranges are two numbers separated with
* a hyphen. The specified range is inclusive. For example, 8-11 for an ‘‘hours’’
* entry specifies execution at hours 8, 9, 10 and 11.
*
* Lists are allowed. A list is a set of numbers (or ranges) separated by commas.
* Examples: ‘‘1,2,5,9’’, ‘‘0-4,8-12’’.
*
* Step values can be used in conjunction with ranges. Following a range with
* ‘‘/<number>’’ specifies skips of the number’s value through the range.
* For example, ‘‘0-23/2’’ can be used in the hours field to specify command
* execution every other hour (the alternative in the V7 standard is ‘‘0,2,4,6,8,10,12,14,16,18,20,22’’).
* Steps are also permitted after an asterisk, so if you want to say ‘‘every two hours’’, just use ‘‘* /2’’
* Names can also be used for the ‘‘month’’ and ‘‘day of week’’ fields. Use the
* first three letters of the particular day or month (case doesn’t matter). Ranges or lists
* of names are not allowed.
*/
var $field = "";
var $activeValues = array();
var $timespan; # contains a DeltaTimestampObj which knows how long one iteration of this field is.
function __construct($field, $possibleValues, $timespan) {
/*
* Constructor.
* Initialize all arrays as needed.
*/
// Just the contents of the field as string.
$this->field = $field;
/*
* create a list with key is value and value is true or false - depending
* on if it's active or not.
*/
foreach ($possibleValues as $possibleValue) {
$this->activeValues[$possibleValue] = false;
}
$this->parseField();
$this->timespan = $timespan;
}
function __toString() {
return sprintf("<%s value %s; timespan (%s)>", get_class($this), $this->field, $this->timespan);
}
function activate($value) {
/*
* activates a key in $activeValues
*/
$value = $this->correctValue($value);
if (isset($this->activeValues[$value]))
$this->activeValues[$value] = true;
else
throw new CronException('Illegal value: '.$value);
}
function correctValue($val) {
/*
* This is a callback method that should be overwritten if you
* need to support correction of entries like if you need
* 0 or 7 for sunday, or if you want to use names.
* Simply react on $val and return any valid int.
*/
// Default: Do nothing
return $val;
}
function activateRange($range) {
/*
* Activates a range. A range is a string which could contain following values:
* 1-3
* 1-4/2
* *
* * /3 (ignore the whitspace)
* and also following legal values, which wrap over the bounds:
* 5-1
* 5-5
* and also following illegal values.
* 1-1/0.5
*/
$matches = array();
if (strpos($range, '*') === 0) {
$tmpmatches = array();
$valid = preg_match('#^\*/?([0-9]*)$#', $range, $tmpmatches);
# modify it to the other kind of range:
$values = $this->getPossibleValues();
$matches[1] = array_shift($values);
$matches[2] = array_pop($values);
$matches[3] = $tmpmatches[1];
} else {
$valid = preg_match('#^([0-9a-zA-Z]+)-([0-9a-zA-Z]+)/?([0-9]*)$#', $range, $matches);
}
if (!$valid)
throw new CronException("Invalid Range: ".$range);
$begin = $this->correctValue($matches[1]);
$end = $this->correctValue($matches[2]);
$step = $matches[3] ? $matches[3] : 1;
/*
* Algorithm:
* We get the list getPossibleValues twice and search for $begin and
* remember the position.
* Then we search for $end AFTER the position of $begin and also remember
* the position.
* Then we have two indices between which we only have to iterate with $step.
*/
$values = $this->getPossibleValues();
// special case: If step is bigger than the list is long, DON'T activate
// anything. If you don't like this behavour, simply wipe out the following
// line. Then the value in $begin is activated. I consider this a bug though.
if ($step > count($values)) return;
$doubleValues = array_merge($values, $values);
$beginPos = array_search($begin, $doubleValues);
$endPos = array_search($end, $doubleValues);
if ($endPos <= $beginPos)
$endPos += count($values);
// iterate. And include edges.
for ($i = $beginPos; $i <= $endPos; $i += $step) {
$this->activate($doubleValues[$i]);
}
}
function getActiveValues() {
/*
* Returns a list of values where active == true
*/
$ret = array();
foreach ($this->activeValues as $value => $active)
if ($active) $ret[] = $value;
sort($ret);
return $ret;
}
function isActive($value) {
/*
* checks if a given value is active
*/
return $this->activeValues[$value];
}
function getPossibleValues() {
/*
* Returns all possible values in a list.
*/
$ret = array_keys($this->activeValues);
sort($ret);
return $ret;
}
function parseField() {
/*
* Parses the contents of the field and creates active values from it.
* This method handles the following possibilities:
*
* **range**
* Something in the format #^([0-9]+)-([0-9]+)/?([0-9]*)$#
*
* **range with wildcard**
* Something in the format #^* /?([0-9]*)$#
*
* **single value**
* like 4 or Sun
*/
$entries = explode(',',$this->field);
foreach ($entries as $entry) {
if (strval(intval($entry)) == $entry || (strpos($entry,"-")===false && strpos($entry,"*")===false)) {
$this->activate($entry);
} else {
$this->activateRange($entry);
}
}
}
function hit($timestampObj) {
/*
* Checks, if the given timestamp-object is a hit for this field.
* PLEASE NOTE: THIS IS NOT AN INTEGER!
* returns true, if hit.
*/
return false;
}
function debug($timestampObj) {
/*
* outputs the state of the object in a nice way
*/
print get_class($this)."\n";
print join(',', $this->getActiveValues());
print "\n";
print "Hit: ".$this->hit($timestampObj)."\n";
print "\n";
}
}
class MinuteField extends CronField {
function __construct($field) {
parent::__construct($field, range(0,59), new DeltaTimestampObj(0,0,0,1,0));
}
function hit($timestampObj) {
return $this->isActive($timestampObj->minute());
}
}
class HourField extends CronField {
function __construct($field) {
parent::__construct($field, range(0,23), new DeltaTimestampObj(0,0,1,0,0));
}
function correctValue($val) {
if ($val == 24) return 0;
return $val;
}
function hit($timestampObj) {
return $this->isActive($timestampObj->hour());
}
}
class DomField extends CronField {
function __construct($field) {
parent::__construct($field, range(0,31), new DeltaTimestampObj(0,1,0,0,0));
}
function hit($timestampObj) {
return $this->isActive($timestampObj->dom());
}
}
class MonthField extends CronField {
function __construct($field) {
parent::__construct($field, range(1,12), new DeltaTimestampObj(1,0,0,0,0));
}
function hit($timestampObj) {
return $this->isActive($timestampObj->month());
}
}
class DowField extends CronField {
function __construct($field) {
parent::__construct($field, range(0,6), new DeltaTimestampObj(0,1,0,0,0));
}
function correctValue($val) {
if ($val == 7) return 0;
$weekdays = array('sun','mon','tue','wed','thu','fri','sat');
if ($pos = array_search(strtolower($val), $weekdays)) return $pos;
return $val;
}
function hit($timestampObj) {
return $this->isActive($timestampObj->dow());
}
}
class ConvenientCron {
/*
* This wraps several methods into single, most-likely-usecases.
*/
var $cron;
var $searcher;
var $error;
function __construct($cron_entry, $timestamp=null) {
/*
* cron_entry is something like "30 7 0 * *"
* timestamp is a integer unix-timestamp.
*/
if (!$timestamp) $timestamp = time();
$this->cron = new Cron($cron_entry, new Timestamp($timestamp));
}
function next() {
/*
* Returns a Timestamp object
* It emulates an iterator so you can call it multiple times.
*/
if ($this->error) return new Timestamp(0); // sorry, w/o exception handling I don't know any better way.
if (!$this->searcher) $this->searcher = new EliminateSearcher($this->cron);
$to = $this->searcher->next();
$ret_to = $to->copy();
$this->searcher->cron->moveInTime(new DeltaTimestampObj(0,0,0,1,0));
return $ret_to;
}
function previous() {
/*
* Returns a Timestamp object
* It emulates an iterator so you can call it multiple times.
*/
if ($this->error) return new Timestamp(0);
if (!$this->searcher) $this->searcher = new EliminateSearcher($this->cron);
$to = $this->searcher->previous();
$ret_to = $to->copy();
$this->searcher->cron->moveInTime(new DeltaTimestampObj(0,0,0,-1,0));
return $ret_to;
}
}
function test_Cron() {
$cron = new Cron("30 9 * * 1");
$expected = "<Cron Fields: (<MinuteField value 30; timespan (0 months 0 days 0 hours 1 minutes 0 seconds)> <HourField value 9; timespan (0 months 0 days 1 hours 0 minutes 0 seconds)> <DomField value *; timespan (0 months 1 days 0 hours 0 minutes 0 seconds)> <MonthField value *; timespan (1 months 0 days 0 hours 0 minutes 0 seconds)> <DowField value 1; timespan (0 months 1 days 0 hours 0 minutes 0 seconds)>)>";
assert($cron == $expected);
print ".";
}
function test_ConvenientCron() {
$cron = new ConvenientCron("30 9 * * 1", 1276088500); # 2010-06-09 15:something
$expected1 = "Mon, 14 Jun 2010 09:30:00 +0200";
$expected2 = "Mon, 21 Jun 2010 09:30:00 +0200";
$expected3 = "Mon, 28 Jun 2010 09:30:00 +0200";
assert($cron->next()->rfc2822() == $expected1);
assert($cron->next()->rfc2822() == $expected2);
assert($cron->next()->rfc2822() == $expected3);
print ".";
}
//test_Cron();
//test_ConvenientCron();
?>