* * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libcalendaring_recurrence { protected $lib; protected $start; protected $engine; protected $recurrence; protected $dateonly = false; protected $event; protected $duration; protected $isStart = true; /** * Default constructor * * @param libcalendaring $lib The libcalendaring plugin instance * @param array $event The event object to operate on */ public function __construct($lib, $event = null) { $this->lib = $lib; $this->event = $event; if (!empty($event)) { if (!empty($event['start']) && is_object($event['start']) && !empty($event['end']) && is_object($event['end']) ) { $this->duration = $event['start']->diff($event['end']); } $this->init($event['recurrence'], $event['start']); } } /** * Initialize recurrence engine * * @param array $recurrence The recurrence properties * @param DateTime $start The recurrence start date */ public function init($recurrence, $start) { $this->start = $start; $this->dateonly = !empty($start->_dateonly) || !empty($this->event['allday']); $this->recurrence = $recurrence; $event = [ 'uid' => '1', 'allday' => $this->dateonly, 'recurrence' => $recurrence, 'start' => $start, // TODO: END/DURATION ??? // TODO: moved occurrences ??? ]; $vcalendar = new libcalendaring_vcalendar($this->lib->timezone); $ve = $vcalendar->toSabreComponent($event); $this->engine = new EventIterator($ve, null, $this->lib->timezone); } /** * Get date/time of the next occurence of this event, and push the iterator. * * @return DateTime|false object or False if recurrence ended */ public function next_start() { try { $this->engine->next(); $current = $this->engine->getDtStart(); } catch (Exception $e) { // do nothing } return !empty($current) ? $this->toDateTime($current) : false; } /** * Get the next recurring instance of this event * * @return array|false Array with event properties or False if recurrence ended */ public function next_instance() { // Here's the workaround for an issue for an event with its start date excluded // E.g. A daily event starting on 10th which is one of EXDATE dates // should return 11th as next_instance() when called for the first time. // Looks like Sabre is setting internal "current date" to 11th on such an object // initialization, therefore calling next() would move it to 12th. if ($this->isStart && ($next_start = $this->engine->getDtStart()) && $next_start->format('Ymd') != $this->start->format('Ymd') ) { $next_start = $this->toDateTime($next_start); } else { $next_start = $this->next_start(); } $this->isStart = false; if ($next_start) { $next = $this->event; $next['start'] = $next_start; if ($this->duration) { $next['end'] = clone $next_start; $next['end']->add($this->duration); } $next['recurrence_date'] = clone $next_start; $next['_instance'] = libcalendaring::recurrence_instance_identifier($next); unset($next['_formatobj']); return $next; } return false; } /** * Get the date of the end of the last occurrence of this recurrence cycle * * @return DateTime|false End datetime of the last occurrence or False if there's no end date */ public function end() { // recurrence end date is given if (isset($this->recurrence['UNTIL']) && $this->recurrence['UNTIL'] instanceof DateTimeInterface) { return $this->recurrence['UNTIL']; } // Run through all items till we reach the end, or limit of iterations // Note: Sabre has a limits of iteration in VObject\Settings, so it is not an infinite loop try { foreach ($this->engine as $end) { // do nothing } } catch (Exception $e) { // do nothing } /* if (empty($end) && isset($this->event['start']) && $this->event['start'] instanceof DateTimeInterface) { // determine a reasonable end date if none given $end = clone $this->event['start']; $end->add(new DateInterval('P100Y')); } */ return isset($end) ? $this->toDateTime($end) : false; } /** * Find date/time of the first occurrence (excluding start date) * * @return DateTime|null First occurrence */ public function first_occurrence() { $start = clone $this->start; $interval = $this->recurrence['INTERVAL'] ?? 1; $freq = $this->recurrence['FREQ'] ?? null; switch ($freq) { case 'WEEKLY': if (empty($this->recurrence['BYDAY'])) { return $start; } $start->sub(new DateInterval("P{$interval}W")); break; case 'MONTHLY': if (empty($this->recurrence['BYDAY']) && empty($this->recurrence['BYMONTHDAY'])) { return $start; } $start->sub(new DateInterval("P{$interval}M")); break; case 'YEARLY': if (empty($this->recurrence['BYDAY']) && empty($this->recurrence['BYMONTH'])) { return $start; } $start->sub(new DateInterval("P{$interval}Y")); break; case 'DAILY': if (!empty($this->recurrence['BYMONTH'])) { break; } // no break default: return $start; } $recurrence = $this->recurrence; if (!empty($recurrence['COUNT'])) { // Increase count so we do not stop the loop to early $recurrence['COUNT'] += 100; } // Create recurrence that starts in the past $self = new self($this->lib); $self->init($recurrence, $start); // TODO: This method does not work the same way as the kolab_date_recurrence based on // kolabcalendaring. I.e. if an event start date does not match the recurrence rule // it will be returned, kolab_date_recurrence will return the next occurrence in such a case // which is the intended result of this function. // See some commented out test cases in tests/RecurrenceTest.php // find the first occurrence $found = false; while ($next = $self->next_start()) { $start = $next; if ($next >= $this->start) { $found = true; break; } } if (!$found) { rcube::raise_error( [ 'file' => __FILE__, 'line' => __LINE__, 'message' => sprintf( "Failed to find a first occurrence. Start: %s, Recurrence: %s", $this->start->format(DateTime::ISO8601), json_encode($recurrence) ), ], true ); return null; } return $this->toDateTime($start); } /** * Convert any DateTime into libcalendaring_datetime */ protected function toDateTime($date, $useStart = true) { if ($date instanceof DateTimeInterface) { $date = libcalendaring_datetime::createFromFormat( 'Y-m-d\\TH:i:s', $date->format('Y-m-d\\TH:i:s'), // Sabre will loose timezone on all-day events, use the event start's timezone $this->start->getTimezone() ); } $date->_dateonly = $this->dateonly; if ($useStart && $this->dateonly) { // Sabre sets time to 00:00:00 for all-day events, // let's copy the time from the event's start $date->setTime((int) $this->start->format('H'), (int) $this->start->format('i'), (int) $this->start->format('s')); } return $date; } }