diff --git a/admin/inc/class.admin_cmd.inc.php b/admin/inc/class.admin_cmd.inc.php index 955fcba09c..0b71f9611a 100644 --- a/admin/inc/class.admin_cmd.inc.php +++ b/admin/inc/class.admin_cmd.inc.php @@ -45,6 +45,10 @@ use EGroupware\Api\Acl; * foreign key into egw_admin_remote (table of remote systems administrated by this one) * @property-read int $account account_id of user affected by this cmd or NULL * @property-read string $app app-name affected by this cmd or NULL + * @property-read string $parent parent cmd (with rrule) of single periodic execution + * @property-read string $rrule rrule for periodic execution + * @property int $rrule_start optional start timestamp for rrule, default $created time + * @property string async_job_id optional name of async job for periodic-run, default "admin-cmd-$id" */ abstract class admin_cmd { @@ -67,7 +71,7 @@ abstract class admin_cmd * * @var int */ - protected $status; + protected $status = self::successful; static $stati = array( admin_cmd::scheduled => 'scheduled', @@ -96,6 +100,8 @@ abstract class admin_cmd public $remote_id; protected $account; protected $app; + protected $rrule; + protected $parent; /** * Display name of command, default ucfirst(str_replace(['_cmd_', '_'], ' ', __CLASS__)) @@ -387,6 +393,16 @@ abstract class admin_cmd { admin_cmd::_set_async_job(); } + // schedule periodic execution, if we have an rrule + elseif (!empty($this->rrule)) + { + $this->set_periodic_job(); + } + // existing object with no rrule, cancle evtl. running periodic job + elseif($vars['id']) + { + $this->cancel_periodic_job(); + } return true; } @@ -418,12 +434,12 @@ abstract class admin_cmd } /** - * reading a command from the queue returning the comand object + * Reading a command from the queue returning the comand object * * @static * @param int|string $id id or uid of the command * @return admin_cmd or null if record not found - * @throws Exception(lang('Unknown command %1!',$class),0); + * @throws Api\Exception\WrongParameter if class does not exist or is no instance of admin_cmd */ static function read($id) { @@ -977,6 +993,72 @@ abstract class admin_cmd return admin_cmd::_set_async_job(); } + const PERIOD_ASYNC_ID_PREFIX = 'admin-cmd-'; + + /** + * Schedule next execution of a periodic job + * + * @return boolean + */ + public function set_periodic_job() + { + if (empty($this->rrule)) return false; + + // parse rrule and calculate next execution time + $event = calendar_rrule::parseRrule($this->rrule, true); // true: allow HOURLY or MINUTELY + // rrule can depend on start-time, use policy creation time by default, if rrule_start is not set + $event['start'] = empty($this->rrule_start) ? $this->created : $this->rrule_start; + $event['tzid'] = Api\DateTime::$server_timezone->getName(); + $rrule = calendar_rrule::event2rrule($event, false); // false = server timezone + $rrule->rewind(); + while((($time = $rrule->current()->format('ts'))) <= time()) + { + $rrule->next(); + } + + // schedule run_periodic_job to run at that time + $async = new Api\Asyncservice(); + $job_id = empty($this->async_job_id) ? self::PERIOD_ASYNC_ID_PREFIX.$this->id : $this->async_job_id; + $async->cancel_timer($job_id); // we delete it in case a job already exists + return $async->set_timer($time, $job_id, __CLASS__.'::run_periodic_job', $this->as_array(), $this->creator); + } + + /** + * Cancel evtl. existing periodic job + * + * @return boolean true if job was canceled, false otherwise + */ + protected function cancel_periodic_job() + { + $async = new Api\Asyncservice(); + $job_id = empty($this->async_job_id) ? self::PERIOD_ASYNC_ID_PREFIX.$this->id : $this->async_job_id; + $async->cancel_timer($job_id); // we delete it in case a job already exists + } + + /** + * Run a periodic job, record it's result and schedule next run + */ + static function run_periodic_job($data) + { + $cmd = admin_cmd::read($data['id']); + + // schedule next execution + $cmd->set_periodic_job(); + + // instanciate single periodic execution object + $single = $cmd->as_array(); + $single['parent'] = $single['id']; + unset($single['id'], $single['uid'], $single['rrule'], $single['created'], $single['modified'], $single['modifier']); + $periodic = admin_cmd::instanciate($single); + + try { + $periodic->run(null, false); + } + catch (Exception $ex) { + error_log(__METHOD__."(".array2string($data).") periodic execution failed: ".$ex->getMessage()); + } + } + /** * Return a list of defined remote instances * diff --git a/admin/inc/class.admin_cmds.inc.php b/admin/inc/class.admin_cmds.inc.php index 7720aec09e..c9eb35056a 100644 --- a/admin/inc/class.admin_cmds.inc.php +++ b/admin/inc/class.admin_cmds.inc.php @@ -48,7 +48,8 @@ class admin_cmds $row['title'] = $e->getMessage(); } $row['data'] = !($data = json_php_unserialize($row['data'])) ? '' : - json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + json_encode($data+(empty($row['rrule'])?array():array('rrule' => $row['rrule'])), + JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); if ($row['status'] == admin_cmd::scheduled) { diff --git a/calendar/inc/class.calendar_ical.inc.php b/calendar/inc/class.calendar_ical.inc.php index 76d1dd222e..ad64bba4bb 100644 --- a/calendar/inc/class.calendar_ical.inc.php +++ b/calendar/inc/class.calendar_ical.inc.php @@ -9,7 +9,6 @@ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package calendar * @subpackage export - * @version $Id$ */ use EGroupware\Api; @@ -28,15 +27,6 @@ class calendar_ical extends calendar_boupdate */ var $supportedFields; - var $recur_days_1_0 = array( - MCAL_M_MONDAY => 'MO', - MCAL_M_TUESDAY => 'TU', - MCAL_M_WEDNESDAY => 'WE', - MCAL_M_THURSDAY => 'TH', - MCAL_M_FRIDAY => 'FR', - MCAL_M_SATURDAY => 'SA', - MCAL_M_SUNDAY => 'SU', - ); /** * @var array $status_egw2ical conversation of the participant status egw => ical */ @@ -2640,160 +2630,8 @@ class calendar_ical extends calendar_boupdate $vcardData['location'] = str_replace("\r\n", "\n", $attributes['value']); break; case 'RRULE': - $recurence = $attributes['value']; - $vcardData['recur_interval'] = 1; - $type = preg_match('/FREQ=([^;: ]+)/i',$recurence,$matches) ? $matches[1] : $recurence[0]; - // vCard 2.0 values for all types - if (preg_match('/UNTIL=([0-9TZ]+)/',$recurence,$matches)) - { - $vcardData['recur_enddate'] = $this->vCalendar->_parseDateTime($matches[1]); - // If it couldn't be parsed, treat it as not set - if(is_string($vcardData['recur_enddate'])) - { - unset($vcardData['recur_enddate']); - } - else - { - // iCal defines enddate to be a time and eg. Apple sends 1s less then next recurance, if they split events - self::check_fix_endate($vcardData); - } - } - elseif (preg_match('/COUNT=([0-9]+)/',$recurence,$matches)) - { - $vcardData['recur_count'] = (int)$matches[1]; - } - if (preg_match('/INTERVAL=([0-9]+)/',$recurence,$matches)) - { - $vcardData['recur_interval'] = (int) $matches[1] ? (int) $matches[1] : 1; - } - $vcardData['recur_data'] = 0; - switch($type) - { - case 'D': // 1.0 - $recurenceMatches = null; - if (preg_match('/D(\d+) #(\d+)/', $recurence, $recurenceMatches)) - { - $vcardData['recur_interval'] = $recurenceMatches[1]; - $vcardData['recur_count'] = $recurenceMatches[2]; - } - elseif (preg_match('/D(\d+) (.*)/', $recurence, $recurenceMatches)) - { - $vcardData['recur_interval'] = $recurenceMatches[1]; - $vcardData['recur_enddate'] = $this->vCalendar->_parseDateTime(trim($recurenceMatches[2])); - } - else break; - // fall-through - case 'DAILY': // 2.0 - $vcardData['recur_type'] = MCAL_RECUR_DAILY; - if (stripos($recurence, 'BYDAY') === false) break; - // hack to handle TYPE=DAILY;BYDAY= as WEEKLY, which is true as long as there's no interval - // fall-through - case 'W': - case 'WEEKLY': - $days = array(); - if (preg_match('/W(\d+) *((?i: [AEFHMORSTUW]{2})+)?( +([^ ]*))$/',$recurence, $recurenceMatches)) // 1.0 - { - $vcardData['recur_interval'] = $recurenceMatches[1]; - if (empty($recurenceMatches[2])) - { - $days[0] = strtoupper(substr(date('D', $vcardData['start']),0,2)); - } - else - { - $days = explode(' ',trim($recurenceMatches[2])); - } - - $repeatMatches = null; - if (preg_match('/#(\d+)/',$recurenceMatches[4],$repeatMatches)) - { - if ($repeatMatches[1]) $vcardData['recur_count'] = $repeatMatches[1]; - } - else - { - $vcardData['recur_enddate'] = $this->vCalendar->_parseDateTime($recurenceMatches[4]); - } - - $recur_days = $this->recur_days_1_0; - } - elseif (preg_match('/BYDAY=([^;: ]+)/',$recurence,$recurenceMatches)) // 2.0 - { - $days = explode(',',$recurenceMatches[1]); - $recur_days = $this->recur_days; - } - else // no day given, use the day of dtstart - { - $vcardData['recur_data'] |= 1 << (int)date('w',$vcardData['start']); - $vcardData['recur_type'] = MCAL_RECUR_WEEKLY; - } - if ($days) - { - foreach ($recur_days as $id => $day) - { - if (in_array(strtoupper(substr($day,0,2)),$days)) - { - $vcardData['recur_data'] |= $id; - } - } - $vcardData['recur_type'] = MCAL_RECUR_WEEKLY; - } - break; - - case 'M': - if (preg_match('/MD(\d+)(?: [^ ]+)? #(\d+)/', $recurence, $recurenceMatches)) - { - $vcardData['recur_type'] = MCAL_RECUR_MONTHLY_MDAY; - $vcardData['recur_interval'] = $recurenceMatches[1]; - $vcardData['recur_count'] = $recurenceMatches[2]; - } - elseif (preg_match('/MD(\d+)(?: [^ ]+)? ([0-9TZ]+)/',$recurence, $recurenceMatches)) - { - $vcardData['recur_type'] = MCAL_RECUR_MONTHLY_MDAY; - $vcardData['recur_interval'] = $recurenceMatches[1]; - $vcardData['recur_enddate'] = $this->vCalendar->_parseDateTime($recurenceMatches[2]); - } - elseif (preg_match('/MP(\d+) (.*) (.*) (.*)/',$recurence, $recurenceMatches)) - { - $vcardData['recur_type'] = MCAL_RECUR_MONTHLY_WDAY; - $vcardData['recur_interval'] = $recurenceMatches[1]; - if (preg_match('/#(\d+)/',$recurenceMatches[4],$recurenceMatches)) - { - $vcardData['recur_count'] = $recurenceMatches[1]; - } - else - { - $vcardData['recur_enddate'] = $this->vCalendar->_parseDateTime(trim($recurenceMatches[4])); - } - } - break; - - case 'Y': // 1.0 - if (preg_match('/YM(\d+)(?: [^ ]+)? #(\d+)/', $recurence, $recurenceMatches)) - { - $vcardData['recur_interval'] = $recurenceMatches[1]; - $vcardData['recur_count'] = $recurenceMatches[2]; - } - elseif (preg_match('/YM(\d+)(?: [^ ]+)? ([0-9TZ]+)/',$recurence, $recurenceMatches)) - { - $vcardData['recur_interval'] = $recurenceMatches[1]; - $vcardData['recur_enddate'] = $this->vCalendar->_parseDateTime($recurenceMatches[2]); - } else break; - // fall-through - case 'YEARLY': // 2.0 - if (strpos($recurence, 'BYDAY') === false) - { - $vcardData['recur_type'] = MCAL_RECUR_YEARLY; - break; - } - // handle FREQ=YEARLY;BYDAY= as FREQ=MONTHLY;BYDAY= with 12*INTERVAL - $vcardData['recur_interval'] = $vcardData['recur_interval'] ? - 12*$vcardData['recur_interval'] : 12; - // fall-through - case 'MONTHLY': - // does currently NOT parse BYDAY or BYMONTH, it has to be specified/identical to DTSTART - $vcardData['recur_type'] = strpos($recurence,'BYDAY') !== false ? - MCAL_RECUR_MONTHLY_WDAY : MCAL_RECUR_MONTHLY_MDAY; - break; - } + $vcardData += calendar_rrule::parseRrule($attributes['value']); + if (!empty($vcardData['recur_enddate'])) self::check_fix_endate ($vcardData); break; case 'EXDATE': // current Horde_Icalendar returns dates, no timestamps if ($attributes['values']) diff --git a/calendar/inc/class.calendar_rrule.inc.php b/calendar/inc/class.calendar_rrule.inc.php index ce3d313fd0..e8504248a0 100644 --- a/calendar/inc/class.calendar_rrule.inc.php +++ b/calendar/inc/class.calendar_rrule.inc.php @@ -56,6 +56,15 @@ class calendar_rrule implements Iterator * Yearly recurrance */ const YEARLY = 5; + /** + * Hourly recurrance + */ + const HOURLY = 8; + /** + * Minutely recurrance + */ + const MINUTELY = 7; + /** * Translate recure types to labels * @@ -67,7 +76,7 @@ class calendar_rrule implements Iterator self::WEEKLY => 'Weekly', self::MONTHLY_WDAY => 'Monthly (by day)', self::MONTHLY_MDAY => 'Monthly (by date)', - self::YEARLY => 'Yearly' + self::YEARLY => 'Yearly', ); /** @@ -79,6 +88,8 @@ class calendar_rrule implements Iterator self::MONTHLY_WDAY => 'MONTHLY', // BYDAY={1..7, -1}{MO..SO, last workday} self::MONTHLY_MDAY => 'MONTHLY', // BYMONHTDAY={1..31, -1 for last day of month} self::YEARLY => 'YEARLY', + self::HOURLY => 'HOURLY', + self::MINUTELY => 'MINUTELY', ); /** @@ -250,7 +261,7 @@ class calendar_rrule implements Iterator $this->time = $time instanceof Api\DateTime ? $time : new Api\DateTime($time); - if (!in_array($type,array(self::NONE, self::DAILY, self::WEEKLY, self::MONTHLY_MDAY, self::MONTHLY_WDAY, self::YEARLY))) + if (!in_array($type,array(self::NONE, self::DAILY, self::WEEKLY, self::MONTHLY_MDAY, self::MONTHLY_WDAY, self::YEARLY, self::HOURLY, self::MINUTELY))) { throw new Api\Exception\WrongParameter(__METHOD__."($time,$type,$interval,$enddate,$weekdays,...) type $type is NOT valid!"); } @@ -451,6 +462,14 @@ class calendar_rrule implements Iterator $this->current->modify($this->interval.' year'); break; + case self::HOURLY: + $this->current->modify($this->interval.' hour'); + break; + + case self::MINUTELY: + $this->current->modify($this->interval.' minute'); + break; + default: throw new Api\Exception\AssertionFailed(__METHOD__."() invalid type #$this->type !"); } @@ -787,6 +806,7 @@ class calendar_rrule implements Iterator /** * Generate a rrule from a string generated by __toString(). + * * @param String $rrule Recurrence rule in string format, as generated by __toString() * @param DateTime date Optional date to work from, defaults to today */ @@ -799,16 +819,16 @@ class calendar_rrule implements Iterator $weekdays = 0; $exceptions = array(); - list($type, $sub, $conditions) = explode(' (', $rrule); - if(!$conditions) + list($type, $sub, $conds) = explode(' (', $rrule); + if(!$conds) { - $conditions = $sub; + $conds = $sub; } else { $type .= " ($sub"; } - $conditions = explode(', ', substr($conditions, 0, -1)); + $conditions = explode(', ', substr($conds, 0, -1)); foreach(static::$types as $id => $type_name) { @@ -859,13 +879,14 @@ class calendar_rrule implements Iterator } else if ($condition_name == lang('ends')) { - list($dow, $date) = explode(', ', $value); + list(, $date) = explode(', ', $value); $enddate = new DateTime($date); } } return new calendar_rrule($time,$type_id,$interval,$enddate,$weekdays,$exceptions); } + /** * Get recurrence data (keys 'recur_*') to merge into an event * @@ -939,6 +960,189 @@ class calendar_rrule implements Iterator } } } + + /** + * Parses a DateTime field and returns a unix timestamp. If the + * field cannot be parsed then the original text is returned + * unmodified. + * + * @param string $text The Icalendar datetime field value. + * @param string $tzid =null A timezone identifier. + * + * @return integer A unix timestamp. + */ + private static function parseIcalDateTime($text, $tzid=null) + { + static $vcal = null; + if (!isset($vcal)) $vcal = new Horde_Icalendar; + + return $vcal->_parseDateTime($text, $tzid); + } + + /** + * Parse an iCal recurrence-rule string + * + * @param type $recurence + * @param bool $support_below_daily =false true: support FREQ=HOURLY|MINUTELY + * @return type + */ + public static function parseRrule($recurence, $support_below_daily=false) + { + $vcardData = array(); + $vcardData['recur_interval'] = 1; + $matches = null; + $type = preg_match('/FREQ=([^;: ]+)/i',$recurence,$matches) ? $matches[1] : $recurence[0]; + // vCard 2.0 values for all types + if (preg_match('/UNTIL=([0-9TZ]+)/',$recurence,$matches)) + { + $vcardData['recur_enddate'] = self::parseIcalDateTime($matches[1]); + // If it couldn't be parsed, treat it as not set + if(is_string($vcardData['recur_enddate'])) + { + unset($vcardData['recur_enddate']); + } + } + elseif (preg_match('/COUNT=([0-9]+)/',$recurence,$matches)) + { + $vcardData['recur_count'] = (int)$matches[1]; + } + if (preg_match('/INTERVAL=([0-9]+)/',$recurence,$matches)) + { + $vcardData['recur_interval'] = (int) $matches[1] ? (int) $matches[1] : 1; + } + $vcardData['recur_data'] = 0; + switch($type) + { + case 'D': // 1.0 + $recurenceMatches = null; + if (preg_match('/D(\d+) #(\d+)/', $recurence, $recurenceMatches)) + { + $vcardData['recur_interval'] = $recurenceMatches[1]; + $vcardData['recur_count'] = $recurenceMatches[2]; + } + elseif (preg_match('/D(\d+) (.*)/', $recurence, $recurenceMatches)) + { + $vcardData['recur_interval'] = $recurenceMatches[1]; + $vcardData['recur_enddate'] = self::parseIcalDateTime(trim($recurenceMatches[2])); + } + else break; + // fall-through + case 'DAILY': // 2.0 + $vcardData['recur_type'] = self::DAILY; + if (stripos($recurence, 'BYDAY') === false) break; + // hack to handle TYPE=DAILY;BYDAY= as WEEKLY, which is true as long as there's no interval + // fall-through + case 'W': + case 'WEEKLY': + $days = array(); + if (preg_match('/W(\d+) *((?i: [AEFHMORSTUW]{2})+)?( +([^ ]*))$/',$recurence, $recurenceMatches)) // 1.0 + { + $vcardData['recur_interval'] = $recurenceMatches[1]; + if (empty($recurenceMatches[2])) + { + $days[0] = strtoupper(substr(date('D', $vcardData['start']),0,2)); + } + else + { + $days = explode(' ',trim($recurenceMatches[2])); + } + + $repeatMatches = null; + if (preg_match('/#(\d+)/',$recurenceMatches[4],$repeatMatches)) + { + if ($repeatMatches[1]) $vcardData['recur_count'] = $repeatMatches[1]; + } + else + { + $vcardData['recur_enddate'] = self::parseIcalDateTime($recurenceMatches[4]); + } + } + elseif (preg_match('/BYDAY=([^;: ]+)/',$recurence,$recurenceMatches)) // 2.0 + { + $days = explode(',',$recurenceMatches[1]); + } + else // no day given, use the day of dtstart + { + $vcardData['recur_data'] |= 1 << (int)date('w',$vcardData['start']); + $vcardData['recur_type'] = self::WEEKLY; + } + if ($days) + { + foreach (self::$days as $id => $day) + { + if (in_array(strtoupper(substr($day,0,2)),$days)) + { + $vcardData['recur_data'] |= $id; + } + } + $vcardData['recur_type'] = self::WEEKLY; + } + break; + + case 'M': + if (preg_match('/MD(\d+)(?: [^ ]+)? #(\d+)/', $recurence, $recurenceMatches)) + { + $vcardData['recur_type'] = self::MONTHLY_MDAY; + $vcardData['recur_interval'] = $recurenceMatches[1]; + $vcardData['recur_count'] = $recurenceMatches[2]; + } + elseif (preg_match('/MD(\d+)(?: [^ ]+)? ([0-9TZ]+)/',$recurence, $recurenceMatches)) + { + $vcardData['recur_type'] = self::MONTHLY_MDAY; + $vcardData['recur_interval'] = $recurenceMatches[1]; + $vcardData['recur_enddate'] = self::parseIcalDateTime($recurenceMatches[2]); + } + elseif (preg_match('/MP(\d+) (.*) (.*) (.*)/',$recurence, $recurenceMatches)) + { + $vcardData['recur_type'] = self::MONTHLY_WDAY; + $vcardData['recur_interval'] = $recurenceMatches[1]; + if (preg_match('/#(\d+)/',$recurenceMatches[4],$recurenceMatches)) + { + $vcardData['recur_count'] = $recurenceMatches[1]; + } + else + { + $vcardData['recur_enddate'] = self::parseIcalDateTime(trim($recurenceMatches[4])); + } + } + break; + + case 'Y': // 1.0 + if (preg_match('/YM(\d+)(?: [^ ]+)? #(\d+)/', $recurence, $recurenceMatches)) + { + $vcardData['recur_interval'] = $recurenceMatches[1]; + $vcardData['recur_count'] = $recurenceMatches[2]; + } + elseif (preg_match('/YM(\d+)(?: [^ ]+)? ([0-9TZ]+)/',$recurence, $recurenceMatches)) + { + $vcardData['recur_interval'] = $recurenceMatches[1]; + $vcardData['recur_enddate'] = self::parseIcalDateTime($recurenceMatches[2]); + } else break; + // fall-through + case 'YEARLY': // 2.0 + if (strpos($recurence, 'BYDAY') === false) + { + $vcardData['recur_type'] = self::YEARLY; + break; + } + // handle FREQ=YEARLY;BYDAY= as FREQ=MONTHLY;BYDAY= with 12*INTERVAL + $vcardData['recur_interval'] = $vcardData['recur_interval'] ? + 12*$vcardData['recur_interval'] : 12; + // fall-through + case 'MONTHLY': + // does currently NOT parse BYDAY or BYMONTH, it has to be specified/identical to DTSTART + $vcardData['recur_type'] = strpos($recurence,'BYDAY') !== false ? + self::MONTHLY_WDAY : self::MONTHLY_MDAY; + break; + case 'HOURLY': + if ($support_below_daily) $vcardData['recur_type'] = self::HOURLY; + break; + case 'MINUTELY': + if ($support_below_daily) $vcardData['recur_type'] = self::MINUTELY; + break; + } + return $vcardData; + } } if (isset($_SERVER['SCRIPT_FILENAME']) && $_SERVER['SCRIPT_FILENAME'] == __FILE__) // some tests