diff --git a/plugins/libkolab/lib/kolab_date_recurrence.php b/plugins/libkolab/lib/kolab_date_recurrence.php index 5ab4532e..3197cde5 100644 --- a/plugins/libkolab/lib/kolab_date_recurrence.php +++ b/plugins/libkolab/lib/kolab_date_recurrence.php @@ -1,256 +1,238 @@ * * Copyright (C) 2012-2016, Kolab Systems AG * * 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 kolab_date_recurrence { private /* EventCal */ $engine; private /* kolab_format_xcal */ $object; private /* DateTime */ $start; private /* DateTime */ $next; private /* cDateTime */ $cnext; private /* DateInterval */ $duration; - private /* string */ $start_time; - private /* string */ $end_time; + private /* bool */ $allday; /** * Default constructor * * @param kolab_format_xcal The Kolab object to operate on */ function __construct($object) { $data = $object->to_array(); $this->object = $object; $this->engine = $object->to_libcal(); $this->start = $this->next = $data['start']; + $this->allday = !empty($data['allday']); $this->cnext = kolab_format::get_datetime($this->next); - if ($this->start && !empty($data['allday'])) { - $this->start_time = $data['start']->format('H:i:s'); - if ($data['end']) { - $this->end_time = $data['end']->format('H:i:s'); - } - } - if (is_object($data['start']) && is_object($data['end'])) { $this->duration = $data['start']->diff($data['end']); } else { // Prevent from errors when end date is not set (#5307) RFC5545 3.6.1 $seconds = !empty($data['end']) ? ($data['end'] - $data['start']) : 0; $this->duration = new DateInterval('PT' . $seconds . 'S'); } } /** * Get date/time of the next occurence of this event * * @param boolean Return a Unix timestamp instead of a DateTime object + * * @return mixed DateTime object/unix timestamp or False if recurrence ended */ public function next_start($timestamp = false) { $time = false; if ($this->engine && $this->next) { if (($cnext = new cDateTime($this->engine->getNextOccurence($this->cnext))) && $cnext->isValid()) { - $next = kolab_format::php_datetime($cnext); + $next = kolab_format::php_datetime($cnext, $this->start->getTimezone()); $time = $timestamp ? $next->format('U') : $next; + if ($this->allday) { + // it looks that for allday events the occurrence time + // is reset to 00:00:00, this is causing various issues + $next->setTime($this->start->format('G'), $this->start->format('i'), $this->start->format('s')); + $next->_dateonly = true; + } + $this->cnext = $cnext; $this->next = $next; } } return $time; } /** * Get the next recurring instance of this event * * @return mixed Array with event properties or False if recurrence ended */ public function next_instance() { if ($next_start = $this->next_start()) { $next_end = clone $next_start; $next_end->add($this->duration); - $next = $this->object->to_array(); - - // it looks that for allday events the occurrence time - // is reset to 00:00:00, this is causing various issues - if (!empty($next['allday'])) { - if ($this->start_time) { - $time = explode(':', $this->start_time); - $next_start->setTime((int)$time[0], (int)$time[1], (int)$time[2]); - } - if ($this->start_end) { - $time = explode(':', $this->start_end); - $next_end->setTime((int)$time[0], (int)$time[1], (int)$time[2]); - } - } - - $next['start'] = $next_start; - $next['end'] = $next_end; - + $next = $this->object->to_array(); $recurrence_id_format = libkolab::recurrence_id_format($next); + $next['start'] = $next_start; + $next['end'] = $next_end; $next['recurrence_date'] = clone $next_start; $next['_instance'] = $next_start->format($recurrence_id_format); unset($next['_formatobj']); return $next; } return false; } /** * Get the end date of the occurence of this recurrence cycle * * @return DateTime|bool End datetime of the last event or False if recurrence exceeds limit */ public function end() { $event = $this->object->to_array(); // recurrence end date is given if ($event['recurrence']['UNTIL'] instanceof DateTime) { return $event['recurrence']['UNTIL']; } // let libkolab do the work if ($this->engine && ($cend = $this->engine->getLastOccurrence()) && ($end_dt = kolab_format::php_datetime(new cDateTime($cend))) ) { return $end_dt; } // determine a reasonable end date if none given if (!$event['recurrence']['COUNT'] && $event['end'] instanceof DateTime) { $end_dt = clone $event['end']; $end_dt->add(new DateInterval('P100Y')); return $end_dt; } return false; } /** * Find date/time of the first occurrence */ public function first_occurrence() { $event = $this->object->to_array(); $start = clone $this->start; $orig_start = clone $this->start; $interval = intval($event['recurrence']['INTERVAL'] ?: 1); switch ($event['recurrence']['FREQ']) { case 'WEEKLY': if (empty($event['recurrence']['BYDAY'])) { return $orig_start; } $start->sub(new DateInterval("P{$interval}W")); break; case 'MONTHLY': if (empty($event['recurrence']['BYDAY']) && empty($event['recurrence']['BYMONTHDAY'])) { return $orig_start; } $start->sub(new DateInterval("P{$interval}M")); break; case 'YEARLY': if (empty($event['recurrence']['BYDAY']) && empty($event['recurrence']['BYMONTH'])) { return $orig_start; } $start->sub(new DateInterval("P{$interval}Y")); break; case 'DAILY': if (!empty($event['recurrence']['BYMONTH'])) { break; } default: return $orig_start; } $event['start'] = $start; $event['recurrence']['INTERVAL'] = $interval; if ($event['recurrence']['COUNT']) { // Increase count so we do not stop the loop to early $event['recurrence']['COUNT'] += 100; } // Create recurrence that starts in the past $object_type = $this->object instanceof kolab_format_task ? 'task' : 'event'; $object = kolab_format::factory($object_type, 3.0); $object->set($event); $recurrence = new self($object); $orig_date = $orig_start->format('Y-m-d'); $found = false; // find the first occurrence while ($next = $recurrence->next_start()) { $start = $next; if ($next->format('Y-m-d') >= $orig_date) { - if ($event['allday']) { - $next->setTime($orig_start->format('G'), $orig_start->format('i'), $orig_start->format('s')); - } - $found = true; break; } } if (!$found) { rcube::raise_error(array( 'file' => __FILE__, 'line' => __LINE__, 'message' => sprintf("Failed to find a first occurrence. Start: %s, Recurrence: %s", $orig_start->format(DateTime::ISO8601), json_encode($event['recurrence'])), ), true); return null; } - if ($event['allday']) { + if ($this->allday) { $start->_dateonly = true; } return $start; } } diff --git a/plugins/libkolab/tests/kolab_date_recurrence.php b/plugins/libkolab/tests/kolab_date_recurrence.php index 9ea3db7b..4cd138e2 100644 --- a/plugins/libkolab/tests/kolab_date_recurrence.php +++ b/plugins/libkolab/tests/kolab_date_recurrence.php @@ -1,234 +1,258 @@ * * Copyright (C) 2017, Kolab Systems AG * * 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 kolab_date_recurrence_test extends PHPUnit_Framework_TestCase { function setUp() { $rcube = rcmail::get_instance(); $rcube->plugins->load_plugin('libkolab', true, true); } /** * kolab_date_recurrence::first_occurrence() * * @dataProvider data_first_occurrence */ function test_first_occurrence($recurrence_data, $start, $expected) { $start = new DateTime($start); if (!empty($recurrence_data['UNTIL'])) { $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']); } $event = array('start' => $start, 'recurrence' => $recurrence_data); $object = kolab_format::factory('event', 3.0); $object->set($event); $recurrence = new kolab_date_recurrence($object); $first = $recurrence->first_occurrence(); $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); } /** * Data for test_first_occurrence() */ function data_first_occurrence() { // TODO: BYYEARDAY, BYWEEKNO, BYSETPOS, WKST return array( // non-recurring array( array(), // recurrence data '2017-08-31 11:00:00', // start date '2017-08-31 11:00:00', // expected result ), // daily array( array('FREQ' => 'DAILY', 'INTERVAL' => '1'), // recurrence data '2017-08-31 11:00:00', // start date '2017-08-31 11:00:00', // expected result ), // TODO: this one is not supported by the Calendar UI array( array('FREQ' => 'DAILY', 'INTERVAL' => '1', 'BYMONTH' => 1), '2017-08-31 11:00:00', '2018-01-01 11:00:00', ), // weekly array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1'), '2017-08-31 11:00:00', // Thursday '2017-08-31 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE'), '2017-08-31 11:00:00', // Thursday '2017-09-06 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'TH'), '2017-08-31 11:00:00', // Thursday '2017-08-31 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'FR'), '2017-08-31 11:00:00', // Thursday '2017-09-01 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '2'), '2017-08-31 11:00:00', // Thursday '2017-08-31 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '3', 'BYDAY' => 'WE'), '2017-08-31 11:00:00', // Thursday '2017-09-20 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'COUNT' => 1), '2017-08-31 11:00:00', // Thursday '2017-09-06 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'UNTIL' => '2017-09-01'), '2017-08-31 11:00:00', // Thursday '', ), // monthly array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '1'), '2017-09-08 11:00:00', '2017-09-08 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'), '2017-08-31 11:00:00', '2017-09-08 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'), '2017-09-08 11:00:00', '2017-09-08 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '1WE'), '2017-08-16 11:00:00', '2017-09-06 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '-1WE'), '2017-08-16 11:00:00', '2017-08-30 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '2'), '2017-09-08 11:00:00', '2017-09-08 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '2', 'BYMONTHDAY' => '8'), '2017-08-31 11:00:00', '2017-09-08 11:00:00', // ?????? ), // yearly array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1'), '2017-08-16 12:00:00', '2017-08-16 12:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8'), '2017-08-16 12:00:00', '2017-08-16 12:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYDAY' => '-1MO'), '2017-08-16 11:00:00', '2017-12-25 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8', 'BYDAY' => '-1MO'), '2017-08-16 11:00:00', '2017-08-28 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1', 'BYDAY' => '1MO'), '2017-08-16 11:00:00', '2018-01-01 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1,9', 'BYDAY' => '1MO'), '2017-08-16 11:00:00', '2017-09-04 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '2'), '2017-08-16 11:00:00', '2017-08-16 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYMONTH' => '8'), '2017-08-16 11:00:00', '2017-08-16 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYDAY' => '-1MO'), '2017-08-16 11:00:00', '2017-12-25 11:00:00', ), // on dates (FIXME: do we really expect the first occurrence to be on the start date?) array( array('RDATE' => array (new DateTime('2017-08-10 11:00:00 Europe/Warsaw'))), '2017-08-01 11:00:00', '2017-08-01 11:00:00', ), ); } /** * kolab_date_recurrence::first_occurrence() for all-day events * * @dataProvider data_first_occurrence */ function test_first_occurrence_allday($recurrence_data, $start, $expected) { $start = new DateTime($start); if (!empty($recurrence_data['UNTIL'])) { $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']); } $event = array('start' => $start, 'recurrence' => $recurrence_data, 'allday' => true); $object = kolab_format::factory('event', 3.0); $object->set($event); $recurrence = new kolab_date_recurrence($object); $first = $recurrence->first_occurrence(); $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); } -} + /** + * kolab_date_recurrence::next_instance() + */ + function test_next_instance() + { + date_default_timezone_set('America/New_York'); + + $start = new DateTime('2017-08-31 11:00:00', new DateTimeZone('Europe/Berlin')); + $event = array( + 'start' => $start, + 'recurrence' => array('FREQ' => 'WEEKLY', 'INTERVAL' => '1'), + 'allday' => true, + ); + + $object = kolab_format::factory('event', 3.0); + $object->set($event); + + $recurrence = new kolab_date_recurrence($object); + $next = $recurrence->next_instance(); + + $this->assertEquals($start->format('2017-09-07 H:i:s'), $next['start']->format('Y-m-d H:i:s'), 'Same time'); + $this->assertEquals($start->getTimezone()->getName(), $next['start']->getTimezone()->getName(), 'Same timezone'); + $this->assertSame($next['start']->_dateonly, true, '_dateonly flag'); + } +}