Replace Horde with Sabre in libcalendaring_recurrence

This commit is contained in:
Aleksander Machniak 2022-12-02 14:51:33 +01:00
parent ee732ffb07
commit e4271ccfd8
11 changed files with 182 additions and 3282 deletions

View file

@ -3994,7 +3994,6 @@ $("#rcmfd_new_category").keypress(function(event) {
$this->load_driver();
// Use libkolab to compute recurring events (and libkolab plugin)
// Horde-based fallback has many bugs
if (class_exists('kolabformat') && class_exists('kolabcalendaring') && class_exists('kolab_date_recurrence')) {
$object = kolab_format::factory('event', 3.0);
$object->set($event);
@ -4002,9 +4001,8 @@ $("#rcmfd_new_category").keypress(function(event) {
$recurrence = new kolab_date_recurrence($object);
}
else {
// fallback to libcalendaring (Horde-based) recurrence implementation
require_once(__DIR__ . '/lib/calendar_recurrence.php');
$recurrence = new calendar_recurrence($this, $event);
// fallback to libcalendaring recurrence implementation
$recurrence = new libcalendaring_recurrence($this->lib, $event);
}
return $recurrence->first_occurrence();

View file

@ -486,11 +486,8 @@ abstract class calendar_driver
$events = [];
if (!empty($event['recurrence'])) {
// include library class
require_once(dirname(__FILE__) . '/../lib/calendar_recurrence.php');
$rcmail = rcmail::get_instance();
$recurrence = new calendar_recurrence($rcmail->plugins->get_plugin('calendar'), $event);
$rcmail = rcmail::get_instance();
$recurrence = new libcalendaring_recurrence($rcmail->plugins->get_plugin('calendar')->lib, $event);
$recurrence_id_format = libcalendaring::recurrence_id_format($event);
// determine a reasonable end date if none given

View file

@ -808,10 +808,7 @@ class database_driver extends calendar_driver
// create new fake entries
if (!empty($event['recurrence'])) {
// include library class
require_once($this->cal->home . '/lib/calendar_recurrence.php');
$recurrence = new calendar_recurrence($this->cal, $event);
$recurrence = new libcalendaring_recurrence($this->cal->lib, $event);
$count = 0;
$event['allday'] = $event['all_day'];
$duration = $event['start']->diff($event['end']);

View file

@ -1,89 +0,0 @@
<?php
require_once realpath(__DIR__ . '/../../libcalendaring/lib/libcalendaring_recurrence.php');
/**
* Recurrence computation class for the Calendar plugin
*
* Uitility class to compute instances of recurring events.
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2012-2014, 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 calendar_recurrence extends libcalendaring_recurrence
{
private $event;
private $duration;
/**
* Default constructor
*
* @param calendar $cal The calendar plugin instance
* @param array $event The event object to operate on
*/
function __construct($cal, $event)
{
parent::__construct($cal->lib);
$this->event = $event;
if (is_object($event['start']) && is_object($event['end'])) {
$this->duration = $event['start']->diff($event['end']);
}
$event['start']->_dateonly = !empty($event['allday']);
$this->init($event['recurrence'], $event['start']);
}
/**
* Alias of libcalendaring_recurrence::next()
*
* @return mixed DateTime object or False if recurrence ended
*/
public function next_start()
{
return $this->next();
}
/**
* 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()) {
$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, $this->event['allday']);
unset($next['_formatobj']);
return $next;
}
return false;
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,15 @@
<?php
use Sabre\VObject\Recur\EventIterator;
/**
* Recurrence computation class for shared use
* Recurrence computation class for shared use.
*
* Uitility class to compute reccurrence dates from the given rules
* Uitility class to compute recurrence dates from the given rules.
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
* @author Aleksander Machniak <machniak@apheleia-it.ch
*
* Copyright (C) 2012-2014, Kolab Systems AG <contact@kolabsys.com>
* 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
@ -26,24 +28,34 @@ class libcalendaring_recurrence
{
protected $lib;
protected $start;
protected $next;
protected $engine;
protected $recurrence;
protected $dateonly = false;
protected $hour = 0;
protected $event;
protected $duration;
/**
* Default constructor
*
* @param calendar The calendar plugin instance
* @param libcalendaring $lib The libcalendaring plugin instance
* @param array $event The event object to operate on
*/
function __construct($lib)
function __construct($lib, $event = null)
{
// use Horde classes to compute recurring instances
// TODO: replace with something that has less than 6'000 lines of code
require_once(__DIR__ . '/Horde_Date_Recurrence.php');
$this->lib = $lib;
$this->event = $event;
$this->lib = $lib;
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']);
}
}
/**
@ -52,121 +64,117 @@ class libcalendaring_recurrence
* @param array The recurrence properties
* @param DateTime The recurrence start date
*/
public function init($recurrence, $start = null)
public function init($recurrence, $start)
{
$this->start = $start;
$this->dateonly = !empty($start->_dateonly);
$this->recurrence = $recurrence;
$this->engine = new Horde_Date_Recurrence($start);
$this->engine->fromRRule20(libcalendaring::to_rrule($recurrence));
$event = [
'uid' => '1',
'allday' => $this->dateonly,
'recurrence' => $recurrence,
'start' => $start,
// TODO: END/DURATION ???
// TODO: moved occurrences ???
];
$this->set_start($start);
$vcalendar = new libcalendaring_vcalendar($this->lib->timezone);
if (!empty($recurrence['EXDATE'])) {
foreach ((array) $recurrence['EXDATE'] as $exdate) {
if ($exdate instanceof DateTimeInterface) {
$this->engine->addException($exdate->format('Y'), $exdate->format('n'), $exdate->format('j'));
}
}
}
if (!empty($recurrence['RDATE'])) {
foreach ((array) $recurrence['RDATE'] as $rdate) {
if ($rdate instanceof DateTimeInterface) {
$this->engine->addRDate($rdate->format('Y'), $rdate->format('n'), $rdate->format('j'));
}
}
}
$ve = $vcalendar->toSabreComponent($event);
$this->engine = new EventIterator($ve, null, $this->lib->timezone);
}
/**
* Setter for (new) recurrence start date
*
* @param DateTime The recurrence start date
*/
public function set_start($start)
{
$this->start = $start;
$this->dateonly = !empty($start->_dateonly);
$this->next = new Horde_Date($start, $this->lib->timezone->getName());
$this->hour = $this->next->hour;
$this->engine->setRecurStart($this->next);
}
/**
* Get date/time of the next occurence of this event
* 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()
public function next_start()
{
$time = false;
$after = clone $this->next;
$after->mday = $after->mday + 1;
if ($this->next && ($next = $this->engine->nextActiveRecurrence($after))) {
// avoid endless loops if recurrence computation fails
if (!$next->after($this->next)) {
return false;
}
// fix time for all-day events
if ($this->dateonly) {
$next->hour = $this->hour;
$next->min = 0;
}
$this->next = $next;
$time = $this->toDateTime($next);
try {
$this->engine->next();
$current = $this->engine->getDtStart();
}
catch (Exception $e) {
// do nothing
}
return $time;
return $current ? $this->toDateTime($current) : false;
}
/**
* Get the end date of the occurence of this recurrence cycle
* Get the next recurring instance of this event
*
* @return DateTime|bool End datetime of the last occurence or False if recurrence exceeds limit
* @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 ($this->recurrence['UNTIL'] instanceof DateTimeInterface) {
if (isset($this->recurrence['UNTIL']) && $this->recurrence['UNTIL'] instanceof DateTimeInterface) {
return $this->recurrence['UNTIL'];
}
// take the last RDATE entry if set
if (is_array($this->recurrence['RDATE']) && !empty($this->recurrence['RDATE'])) {
$last = end($this->recurrence['RDATE']);
if ($last instanceof DateTimeInterface) {
return $last;
if (!$this->engine->isInfinite()) {
// run through all items till we reach the end
try {
foreach ($this->engine as $end) {
// do nothing
}
}
catch (Exception $e) {
// do nothing
}
}
// run through all items till we reach the end
if ($this->recurrence['COUNT']) {
$last = $this->start;
$this->next = new Horde_Date($this->start, $this->lib->timezone->getName());
while (($next = $this->next()) && $c < 1000) {
$last = $next;
$c++;
}
else if (isset($this->event['end']) && $this->event['end'] instanceof DateTimeInterface) {
// determine a reasonable end date if none given
$end = clone $this->event['end'];
$end->add(new DateInterval('P100Y'));
}
return $last;
return isset($end) ? $this->toDateTime($end, false) : false;
}
/**
* Find date/time of the first occurrence (excluding start date)
*
* @return DateTime|null First occurrence
*/
public function first_occurrence()
{
$start = clone $this->start;
$orig_start = clone $this->start;
$r = $this->recurrence;
$interval = !empty($r['INTERVAL']) ? intval($r['INTERVAL']) : 1;
$frequency = isset($this->recurrence['FREQ']) ? $this->recurrence['FREQ'] : null;
$start = clone $this->start;
$interval = $this->recurrence['INTERVAL'] ?? 1;
$freq = $this->recurrence['FREQ'] ?? null;
switch ($frequency) {
switch ($freq) {
case 'WEEKLY':
if (empty($this->recurrence['BYDAY'])) {
return $start;
@ -191,63 +199,80 @@ class libcalendaring_recurrence
$start->sub(new DateInterval("P{$interval}Y"));
break;
case 'DAILY':
if (!empty($this->recurrence['BYMONTH'])) {
break;
}
default:
return $start;
}
$r = $this->recurrence;
$r['INTERVAL'] = $interval;
if (!empty($r['COUNT'])) {
$recurrence = $this->recurrence;
if (!empty($recurrence['COUNT'])) {
// Increase count so we do not stop the loop to early
$r['COUNT'] += 100;
$recurrence['COUNT'] += 100;
}
// Create recurrence that starts in the past
$recurrence = new self($this->lib);
$recurrence->init($r, $start);
$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 = $recurrence->next()) {
while ($next = $self->next_start()) {
$start = $next;
if ($next >= $orig_start) {
if ($next >= $this->start) {
$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($r)),
), true);
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;
}
$start = $this->toDateTime($start);
return $start;
return $this->toDateTime($start);
}
private function toDateTime($date)
/**
* Convert any DateTime into libcalendaring_datetime
*/
protected function toDateTime($date, $useStart = true)
{
if ($date Instanceof Horde_Date) {
$date = $date->toDateTime();
}
if ($date instanceof DateTimeInterface) {
$date = libcalendaring_datetime::createFromFormat(
'Y-m-d\\TH:i:s',
$date->format('Y-m-d\\TH:i:s'),
$date->getTimezone()
// 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;
}
}

View file

@ -1043,6 +1043,23 @@ class libcalendaring_vcalendar implements Iterator
}
}
/**
* Converts internal event representation to Sabre component
*
* @param array Event
* @param callable Callback function to fetch attachment contents, false if no attachment export
*
* @return Sabre\VObject\Component\VEvent Sabre component
*/
public function toSabreComponent($object, $get_attachment = false)
{
$vcal = new VObject\Component\VCalendar();
$this->_to_ical($object, $vcal, $get_attachment);
return $vcal->getBaseComponent();
}
/**
* Build a valid iCal format block from the given event
*
@ -1097,11 +1114,14 @@ class libcalendaring_vcalendar implements Iterator
$ve->add($recurrence_id);
}
$ve->add('SUMMARY', $event['title']);
if (!empty($event['title'])) {
$ve->add('SUMMARY', $event['title']);
}
if (!empty($event['location'])) {
$ve->add($this->is_apple() ? new vobject_location_property($cal, 'LOCATION', $event['location']) : $cal->create('LOCATION', $event['location']));
}
if (!empty($event['description'])) {
$ve->add('DESCRIPTION', strtr($event['description'], array("\r\n" => "\n", "\r" => "\n"))); // normalize line endings
}

View file

@ -250,23 +250,24 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
}
/**
* Test for libcalendaring_recurrence::next()
* 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'));
$start->_dateonly = true;
$event = [
'start' => $start,
'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'],
'allday' => true,
];
$recurrence = $this->plugin->get_recurrence();
$recurrence = new libcalendaring_recurrence($this->plugin, $event);
$next = $recurrence->next_instance();
$recurrence->init(['FREQ' => 'WEEKLY', 'INTERVAL' => '1'], $start);
$next = $recurrence->next();
$this->assertEquals($start->format('2017-09-07 H:i:s'), $next->format('Y-m-d H:i:s'), 'Same time');
$this->assertEquals($start->getTimezone()->getName(), $next->getTimezone()->getName(), 'Same timezone');
$this->assertTrue($next->_dateonly, '_dateonly flag');
$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');
}
}

View file

@ -119,7 +119,7 @@ class kolab_date_recurrence
}
/**
* Get the end date of the occurence of this recurrence cycle
* 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
*/
@ -152,6 +152,8 @@ class kolab_date_recurrence
/**
* Find date/time of the first occurrence
*
* @return DateTime|null First occurrence
*/
public function first_occurrence()
{

View file

@ -701,7 +701,7 @@ class tasklist extends rcube_plugin
$rec['recurrence'] = $this->lib->from_client_recurrence($rec['recurrence'], $refdate);
// translate count into an absolute end date.
// why? because when shifting completed tasks to the next recurrence,
// why? because when shifting completed tasks to the next occurrence,
// the initial start date to count from gets lost.
if (!empty($rec['recurrence']['COUNT'])) {
$engine = libcalendaring::get_recurrence();
@ -831,7 +831,7 @@ class tasklist extends rcube_plugin
$date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone);
$engine->init($rrule, $date);
if ($next = $engine->next()) {
if ($next = $engine->next_start()) {
$updates[$date_key] = $next->format('Y-m-d');
if (!empty($rec[$time_key]))
$updates[$time_key] = $next->format('H:i');
@ -846,7 +846,7 @@ class tasklist extends rcube_plugin
foreach ($rec['valarms'] as $i => $alarm) {
if ($alarm['trigger'] instanceof DateTime) {
$engine->init($rrule, $alarm['trigger']);
if ($next = $engine->next()) {
if ($next = $engine->next_start()) {
$alarm['trigger'] = $next;
}
}
@ -1382,7 +1382,7 @@ class tasklist extends rcube_plugin
// check task occurrences (stop next week)
// FIXME: is there a faster way of doing this?
while ($date = $engine->next()) {
while ($date = $engine->next_start()) {
$date = $date->format('Y-m-d');
// break iteration asap