diff --git a/plugins/libcalendaring/lib/libcalendaring_recurrence.php b/plugins/libcalendaring/lib/libcalendaring_recurrence.php index 4aca2861..4de96812 100644 --- a/plugins/libcalendaring/lib/libcalendaring_recurrence.php +++ b/plugins/libcalendaring/lib/libcalendaring_recurrence.php @@ -1,278 +1,278 @@ <?php use Sabre\VObject\Recur\EventIterator; /** * Recurrence computation class for shared use. * * Uitility class to compute recurrence dates from the given rules. * * @author Aleksander Machniak <machniak@apheleia-it.ch * * Copyright (C) 2012-2022, Apheleia IT AG <contact@apheleia-it.ch> * * 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 <http://www.gnu.org/licenses/>. */ class libcalendaring_recurrence { protected $lib; protected $start; protected $engine; protected $recurrence; protected $dateonly = false; protected $event; protected $duration; /** * Default constructor * * @param libcalendaring $lib The libcalendaring plugin instance * @param array $event The event object to operate on */ 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']); } $event['start']->_dateonly = !empty($event['allday']); $this->init($event['recurrence'], $event['start']); } } /** * Initialize recurrence engine * * @param array The recurrence properties * @param DateTime The recurrence start date */ public function init($recurrence, $start) { $this->start = $start; $this->dateonly = !empty($start->_dateonly); $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 $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() { if ($next_start = $this->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, !empty($this->event['allday'])); 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']; } - if (!$this->engine->isInfinite()) { - // run through all items till we reach the end - try { - foreach ($this->engine as $end) { - // do nothing - } - } - catch (Exception $e) { + // 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 } } - else if (isset($this->event['end']) && $this->event['end'] instanceof DateTimeInterface) { + 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['end']; + $end = clone $this->event['start']; $end->add(new DateInterval('P100Y')); } - - return isset($end) ? $this->toDateTime($end, false) : false; +*/ + 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; } 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; } } diff --git a/plugins/libcalendaring/tests/RecurrenceTest.php b/plugins/libcalendaring/tests/RecurrenceTest.php index cdbb2a8d..d17257a8 100644 --- a/plugins/libcalendaring/tests/RecurrenceTest.php +++ b/plugins/libcalendaring/tests/RecurrenceTest.php @@ -1,273 +1,337 @@ <?php /** * libcalendaring_recurrence tests * * @author Aleksander Machniak <machniak@apheleia-it.ch> * * Copyright (C) 2022, Apheleia IT AG <contact@apheleia-it.ch> * * 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 <http://www.gnu.org/licenses/>. */ class RecurrenceTest extends PHPUnit\Framework\TestCase { private $plugin; function setUp(): void { $rcube = rcmail::get_instance(); $rcube->plugins->load_plugin('libcalendaring', true, true); $this->plugin = $rcube->plugins->get_plugin('libcalendaring'); } /** - * Test for libcalendaring_recurrence::first_occurrence() - * - * @dataProvider data_first_occurrence + * Data for test_end() */ - function test_first_occurrence($recurrence_data, $start, $expected) + function data_end() { - $start = new DateTime($start); - if (!empty($recurrence_data['UNTIL'])) { - $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']); - } - - $recurrence = $this->plugin->get_recurrence(); - - $recurrence->init($recurrence_data, $start); - $first = $recurrence->first_occurrence(); + return [ + // non-recurring + [ + [ + 'recurrence' => [], + 'start' => new DateTime('2017-08-31 11:00:00') + ], + '2017-08-31 11:00:00', // expected result + ], + // daily + [ + [ + 'recurrence' => ['FREQ' => 'DAILY', 'INTERVAL' => '1', 'COUNT' => 2], + 'start' => new DateTime('2017-08-31 11:00:00') + ], + '2017-09-01 11:00:00', + ], + // weekly + [ + [ + 'recurrence' => ['FREQ' => 'WEEKLY', 'COUNT' => 3], + 'start' => new DateTime('2017-08-31 11:00:00'), // Thursday + ], + '2017-09-14 11:00:00', + ], + // UNTIL + [ + [ + 'recurrence' => ['FREQ' => 'WEEKLY', 'COUNT' => 3, 'UNTIL' => new DateTime('2017-09-07 11:00:00')], + 'start' => new DateTime('2017-08-31 11:00:00'), // Thursday + ], + '2017-09-07 11:00:00', + ], + // Infinite recurrence, no count, no until + [ + [ + 'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'], + 'start' => new DateTime('2017-08-31 11:00:00'), // Thursday + ], + '2084-09-21 11:00:00', + ], - $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); + // TODO: Test an event with EXDATE/RDATEs + ]; } - /** * 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', ), ); } + /** + * Test for libcalendaring_recurrence::end() + * + * @dataProvider data_end + */ + function test_end($event, $expected) + { + $recurrence = new libcalendaring_recurrence($this->plugin, $event); + + $end = $recurrence->end(); + + $this->assertSame($expected, $end ? $end->format('Y-m-d H:i:s') : $end); + } + + /** + * Test for libcalendaring_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']); + } + + $recurrence = $this->plugin->get_recurrence(); + + $recurrence->init($recurrence_data, $start); + $first = $recurrence->first_occurrence(); + + $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); + } + /** * Test for libcalendaring_recurrence::first_occurrence() for all-day events * * @dataProvider data_first_occurrence */ function test_first_occurrence_allday($recurrence_data, $start, $expected) { $start = new libcalendaring_datetime($start); $start->_dateonly = true; if (!empty($recurrence_data['UNTIL'])) { $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']); } $recurrence = $this->plugin->get_recurrence(); $recurrence->init($recurrence_data, $start); $first = $recurrence->first_occurrence(); $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); if ($expected) { $this->assertTrue($first->_dateonly); } } /** * Test for libcalendaring_recurrence::next_instance() */ function test_next_instance() { date_default_timezone_set('America/New_York'); $start = new libcalendaring_datetime('2017-08-31 11:00:00', new DateTimeZone('Europe/Berlin')); $event = [ 'start' => $start, 'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'], 'allday' => true, ]; $recurrence = new libcalendaring_recurrence($this->plugin, $event); $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->assertTrue($next['start']->_dateonly, '_dateonly flag'); } } diff --git a/plugins/libkolab/lib/kolab_date_recurrence.php b/plugins/libkolab/lib/kolab_date_recurrence.php index f2110b03..2aaf5888 100644 --- a/plugins/libkolab/lib/kolab_date_recurrence.php +++ b/plugins/libkolab/lib/kolab_date_recurrence.php @@ -1,241 +1,242 @@ <?php /** * Recurrence computation class for xcal-based Kolab format objects * * Utility class to compute instances of recurring events. * It requires the libcalendaring PHP extension to be installed and loaded. * * @version @package_version@ * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2012-2016, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ 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 /* 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 (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 bool Return a Unix timestamp instead of a DateTime object * * @return DateTime|int|false 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, $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(); $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 last 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 DateTimeInterface) { + if (isset($event['recurrence']['UNTIL']) && $event['recurrence']['UNTIL'] instanceof DateTimeInterface) { 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 DateTimeInterface) { - $end_dt = clone $event['end']; - $end_dt->add(new DateInterval('P100Y')); - - return $end_dt; + // determine a reasonable end date for an infinite recurrence + if (empty($event['recurrence']['COUNT'])) { + if (!empty($event['start']) && $event['start'] instanceof DateTimeInterface) { + $start_dt = clone $event['start']; + $start_dt->add(new DateInterval('P100Y')); + return $start_dt; + } } return false; } /** * Find date/time of the first occurrence * * @return DateTime|null 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) { $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 ($this->allday) { $start->_dateonly = true; } return $start; } } diff --git a/plugins/libkolab/libkolab.php b/plugins/libkolab/libkolab.php index 6c5e7a7a..5b0ecbbe 100644 --- a/plugins/libkolab/libkolab.php +++ b/plugins/libkolab/libkolab.php @@ -1,388 +1,389 @@ <?php /** * Kolab core library * * Plugin to setup a basic environment for the interaction with a Kolab server. * Other Kolab-related plugins will depend on it and can use the library classes * * @version @package_version@ * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2012-2015, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ class libkolab extends rcube_plugin { static $http_requests = array(); static $bonnie_api = false; /** * Required startup method of a Roundcube plugin */ public function init() { // load local config $this->load_config(); + $this->require_plugin('libcalendaring'); // extend include path to load bundled lib classes $include_path = $this->home . '/lib' . PATH_SEPARATOR . ini_get('include_path'); set_include_path($include_path); $this->add_hook('storage_init', array($this, 'storage_init')); $this->add_hook('storage_connect', array($this, 'storage_connect')); $this->add_hook('user_delete', array('kolab_storage', 'delete_user_folders')); // For Chwala $this->add_hook('folder_mod', array('kolab_storage', 'folder_mod')); $rcmail = rcube::get_instance(); try { kolab_format::$timezone = new DateTimeZone($rcmail->config->get('timezone', 'GMT')); } catch (Exception $e) { rcube::raise_error($e, true); kolab_format::$timezone = new DateTimeZone('GMT'); } $this->add_texts('localization/', false); if (!empty($rcmail->output->type) && $rcmail->output->type == 'html') { $rcmail->output->add_handler('libkolab.folder_search_form', array($this, 'folder_search_form')); $this->include_stylesheet($this->local_skin_path() . '/libkolab.css'); } // embed scripts and templates for email message audit trail if ($rcmail->task == 'mail' && self::get_bonnie_api()) { if ($rcmail->output->type == 'html') { $this->add_hook('render_page', array($this, 'bonnie_render_page')); $this->include_script('libkolab.js'); // add 'Show history' item to message menu $this->api->add_content(html::tag('li', array('role' => 'menuitem'), $this->api->output->button(array( 'command' => 'kolab-mail-history', 'label' => 'libkolab.showhistory', 'type' => 'link', 'classact' => 'icon history active', 'class' => 'icon history disabled', 'innerclass' => 'icon history', ))), 'messagemenu'); } $this->register_action('plugin.message-changelog', array($this, 'message_changelog')); } } /** * Hook into IMAP FETCH HEADER.FIELDS command and request Kolab-specific headers */ function storage_init($p) { $kolab_headers = 'X-KOLAB-TYPE X-KOLAB-MIME-VERSION MESSAGE-ID'; if (!empty($p['fetch_headers'])) { $p['fetch_headers'] .= ' ' . $kolab_headers; } else { $p['fetch_headers'] = $kolab_headers; } return $p; } /** * Hook into IMAP connection to replace client identity */ function storage_connect($p) { $client_name = 'Roundcube/Kolab'; if (empty($p['ident'])) { $p['ident'] = array( 'name' => $client_name, 'version' => RCUBE_VERSION, /* 'php' => PHP_VERSION, 'os' => PHP_OS, 'command' => $_SERVER['REQUEST_URI'], */ ); } else { $p['ident']['name'] = $client_name; } return $p; } /** * Getter for a singleton instance of the Bonnie API * * @return mixed kolab_bonnie_api instance if configured, false otherwise */ public static function get_bonnie_api() { // get configuration for the Bonnie API if (!self::$bonnie_api && ($bonnie_config = rcube::get_instance()->config->get('kolab_bonnie_api', false))) { self::$bonnie_api = new kolab_bonnie_api($bonnie_config); } return self::$bonnie_api; } /** * Hook to append the message history dialog template to the mail view */ function bonnie_render_page($p) { if (($p['template'] === 'mail' || $p['template'] === 'message') && !$p['kolab-audittrail']) { // append a template for the audit trail dialog $this->api->output->add_footer( html::div(array('id' => 'mailmessagehistory', 'class' => 'uidialog', 'aria-hidden' => 'true', 'style' => 'display:none'), self::object_changelog_table(array('class' => 'records-table changelog-table')) ) ); $this->api->output->set_env('kolab_audit_trail', true); $p['kolab-audittrail'] = true; } return $p; } /** * Handler for message audit trail changelog requests */ public function message_changelog() { if (!self::$bonnie_api) { return false; } $rcmail = rcube::get_instance(); $msguid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST, true); $mailbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $result = $msguid && $mailbox ? self::$bonnie_api->changelog('mail', null, $mailbox, $msguid) : null; if (is_array($result)) { if (is_array($result['changes'])) { $dtformat = $rcmail->config->get('date_format') . ' ' . $rcmail->config->get('time_format'); array_walk($result['changes'], function(&$change) use ($dtformat, $rcmail) { if ($change['date']) { $dt = rcube_utils::anytodatetime($change['date']); if ($dt instanceof DateTimeInterface) { $change['date'] = $rcmail->format_date($dt, $dtformat); } } }); } $this->api->output->command('plugin.message_render_changelog', $result['changes']); } else { $this->api->output->command('plugin.message_render_changelog', false); } $this->api->output->send(); } /** * Wrapper function to load and initalize the HTTP_Request2 Object * * @param string|Net_Url2 Request URL * @param string Request method ('OPTIONS','GET','HEAD','POST','PUT','DELETE','TRACE','CONNECT') * @param array Configuration for this Request instance, that will be merged * with default configuration * * @return HTTP_Request2 Request object */ public static function http_request($url = '', $method = 'GET', $config = array()) { $rcube = rcube::get_instance(); $http_config = (array) $rcube->config->get('kolab_http_request'); // deprecated configuration options if (empty($http_config)) { foreach (array('ssl_verify_peer', 'ssl_verify_host') as $option) { $value = $rcube->config->get('kolab_' . $option, true); if (is_bool($value)) { $http_config[$option] = $value; } } } if (!empty($config)) { $http_config = array_merge($http_config, $config); } // force CURL adapter, this allows to handle correctly // compressed responses with SplObserver registered (kolab_files) (#4507) $http_config['adapter'] = 'HTTP_Request2_Adapter_Curl'; $key = md5(serialize($http_config)); if (!($request = self::$http_requests[$key])) { // load HTTP_Request2 (support both composer-installed and system-installed package) if (!class_exists('HTTP_Request2')) { require_once 'HTTP/Request2.php'; } try { $request = new HTTP_Request2(); $request->setConfig($http_config); } catch (Exception $e) { rcube::raise_error($e, true, true); } // proxy User-Agent string $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); self::$http_requests[$key] = $request; } // cleanup try { $request->setBody(''); $request->setUrl($url); $request->setMethod($method); } catch (Exception $e) { rcube::raise_error($e, true, true); } return $request; } /** * Table oultine for object changelog display */ public static function object_changelog_table($attrib = array()) { $rcube = rcube::get_instance(); $attrib += array('domain' => 'libkolab'); $table = new html_table(array('cols' => 5, 'border' => 0, 'cellspacing' => 0)); $table->add_header('diff', ''); $table->add_header('revision', $rcube->gettext('revision', $attrib['domain'])); $table->add_header('date', $rcube->gettext('date', $attrib['domain'])); $table->add_header('user', $rcube->gettext('user', $attrib['domain'])); $table->add_header('operation', $rcube->gettext('operation', $attrib['domain'])); $table->add_header('actions', ' '); $rcube->output->add_label( 'libkolab.showrevision', 'libkolab.actionreceive', 'libkolab.actionappend', 'libkolab.actionmove', 'libkolab.actiondelete', 'libkolab.actionread', 'libkolab.actionflagset', 'libkolab.actionflagclear', 'libkolab.objectchangelog', 'libkolab.objectchangelognotavailable', 'close' ); return $table->show($attrib); } /** * Wrapper function for generating a html diff using the FineDiff class by Raymond Hill */ public static function html_diff($from, $to, $is_html = null) { // auto-detect text/html format if ($is_html === null) { $from_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $from, $m) && strpos($from, '</'.$m[1].'>') > 0); $to_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $to, $m) && strpos($to, '</'.$m[1].'>') > 0); $is_html = $from_html || $to_html; // ensure both parts are of the same format if ($is_html && !$from_html) { $converter = new rcube_text2html($from, false, array('wrap' => true)); $from = $converter->get_html(); } if ($is_html && !$to_html) { $converter = new rcube_text2html($to, false, array('wrap' => true)); $to = $converter->get_html(); } } // compute diff from HTML if ($is_html) { include_once __dir__ . '/vendor/Caxy/HtmlDiff/Match.php'; include_once __dir__ . '/vendor/Caxy/HtmlDiff/Operation.php'; include_once __dir__ . '/vendor/Caxy/HtmlDiff/HtmlDiff.php'; // replace data: urls with a transparent image to avoid memory problems $from = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $from); $to = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $to); $diff = new Caxy\HtmlDiff\HtmlDiff($from, $to); $diffhtml = $diff->build(); // remove empty inserts (from tables) return preg_replace('!<ins class="diff\w+">\s*</ins>!Uims', '', $diffhtml); } else { include_once __dir__ . '/vendor/finediff.php'; $diff = new FineDiff($from, $to, FineDiff::$wordGranularity); return $diff->renderDiffToHTML(); } } /** * Return a date() format string to render identifiers for recurrence instances * * @param array Hash array with event properties * @return string Format string */ public static function recurrence_id_format($event) { return $event['allday'] ? 'Ymd' : 'Ymd\THis'; } /** * Returns HTML code for folder search widget * * @param array $attrib Named parameters * * @return string HTML code for the gui object */ public function folder_search_form($attrib) { $rcmail = rcube::get_instance(); $attrib += array( 'gui-object' => false, 'wrapper' => true, 'form-name' => 'foldersearchform', 'command' => 'non-extsing-command', 'reset-command' => 'non-existing-command', ); if ($attrib['label-domain'] && !strpos($attrib['buttontitle'], '.')) { $attrib['buttontitle'] = $attrib['label-domain'] . '.' . $attrib['buttontitle']; } if ($attrib['buttontitle']) { $attrib['placeholder'] = $rcmail->gettext($attrib['buttontitle']); } return $rcmail->output->search_form($attrib); } } diff --git a/plugins/libkolab/tests/KolabDateRecurrenceTest.php b/plugins/libkolab/tests/KolabDateRecurrenceTest.php index 3c51aa19..c2ba0208 100644 --- a/plugins/libkolab/tests/KolabDateRecurrenceTest.php +++ b/plugins/libkolab/tests/KolabDateRecurrenceTest.php @@ -1,270 +1,342 @@ <?php /** * kolab_date_recurrence tests * * @author Aleksander Machniak <machniak@kolabsys.com> * * Copyright (C) 2017, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ class KolabDateRecurrenceTest extends PHPUnit\Framework\TestCase { function setUp(): void { $rcube = rcmail::get_instance(); $rcube->plugins->load_plugin('libkolab', true, true); + $rcube->plugins->load_plugin('libcalendaring', true, true); } /** - * kolab_date_recurrence::first_occurrence() - * - * @dataProvider data_first_occurrence + * Data for test_end() */ - function test_first_occurrence($recurrence_data, $start, $expected) + function data_end() { - if (!kolab_format::supports(3)) { - $this->markTestSkipped('No Kolab support'); - } - - $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(); + return [ + // non-recurring + [ + [ + 'recurrence' => [], + 'start' => new DateTime('2017-08-31 11:00:00') + ], + '2117-08-31 11:00:00', // expected result + ], + // daily + [ + [ + 'recurrence' => ['FREQ' => 'DAILY', 'INTERVAL' => '1', 'COUNT' => 2], + 'start' => new DateTime('2017-08-31 11:00:00') + ], + '2017-09-01 11:00:00', + ], + // weekly + [ + [ + 'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'COUNT' => 3], + 'start' => new DateTime('2017-08-31 11:00:00'), // Thursday + ], + '2017-09-14 11:00:00', + ], + // UNTIL + [ + [ + 'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'COUNT' => 3, 'UNTIL' => new DateTime('2017-09-07 11:00:00')], + 'start' => new DateTime('2017-08-31 11:00:00'), // Thursday + ], + '2017-09-07 11:00:00', + ], + // Infinite recurrence, no count, no until + [ + [ + 'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'], + 'start' => new DateTime('2017-08-31 11:00:00'), // Thursday + ], + '2117-08-31 11:00:00', + ], - $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); + // TODO: Test an event with EXDATE/RDATE + ]; } /** * 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::end() + * + * @dataProvider data_end + */ + function test_end($event, $expected) + { + if (!kolab_format::supports(3)) { + $this->markTestSkipped('No Kolab support'); + } + + $object = kolab_format::factory('event', 3.0); + $object->set($event); + + $recurrence = new kolab_date_recurrence($object); + $end = $recurrence->end(); + + $this->assertSame($expected, $end ? $end->format('Y-m-d H:i:s') : $end); + } + + /** + * kolab_date_recurrence::first_occurrence() + * + * @dataProvider data_first_occurrence + */ + function test_first_occurrence($recurrence_data, $start, $expected) + { + if (!kolab_format::supports(3)) { + $this->markTestSkipped('No Kolab support'); + } + + $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') : ''); + } + /** * kolab_date_recurrence::first_occurrence() for all-day events * * @dataProvider data_first_occurrence */ function test_first_occurrence_allday($recurrence_data, $start, $expected) { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $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() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } date_default_timezone_set('America/New_York'); $start = new DateTime('2017-08-31 11:00:00', new DateTimeZone('Europe/Berlin')); $event = [ 'start' => $start, 'recurrence' => ['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(true, $next['start']->_dateonly, '_dateonly flag'); } }