diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index a42bd3e1..d1b8eabb 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -857,8 +857,8 @@ class calendar extends rcube_plugin if ($success && $reload) $this->rc->output->command('plugin.reload_view'); } - - + + /** * Dispatcher for event actions initiated by the client */ @@ -867,7 +867,7 @@ class calendar extends rcube_plugin $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $event = rcube_utils::get_input_value('e', rcube_utils::INPUT_POST, true); $success = $reload = $got_msg = false; - + // force notify if hidden + active if ((int)$this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']) === 1) $event['_notify'] = 1; @@ -887,8 +887,10 @@ class calendar extends rcube_plugin case "new": // create UID for new event $event['uid'] = $this->generate_uid(); - $this->write_preprocess($event, $action); - if ($success = $this->driver->new_event($event)) { + if (!$this->write_preprocess($event, $action)) { + $got_msg = true; + } + else if ($success = $this->driver->new_event($event)) { $event['id'] = $event['uid']; $event['_savemode'] = 'all'; $this->cleanup_event($event); @@ -898,8 +900,10 @@ class calendar extends rcube_plugin break; case "edit": - $this->write_preprocess($event, $action); - if ($success = $this->driver->edit_event($event)) { + if (!$this->write_preprocess($event, $action)) { + $got_msg = true; + } + else if ($success = $this->driver->edit_event($event)) { $this->cleanup_event($event); $this->event_save_success($event, $old, $action, $success); } @@ -907,19 +911,23 @@ class calendar extends rcube_plugin break; case "resize": - $this->write_preprocess($event, $action); - if ($success = $this->driver->resize_event($event)) { + if (!$this->write_preprocess($event, $action)) { + $got_msg = true; + } + else if ($success = $this->driver->resize_event($event)) { $this->event_save_success($event, $old, $action, $success); } $reload = $event['_savemode'] ? 2 : 1; break; case "move": - $this->write_preprocess($event, $action); - if ($success = $this->driver->move_event($event)) { + if (!$this->write_preprocess($event, $action)) { + $got_msg = true; + } + else if ($success = $this->driver->move_event($event)) { $this->event_save_success($event, $old, $action, $success); } - $reload = $success && $event['_savemode'] ? 2 : 1; + $reload = $success && $event['_savemode'] ? 2 : 1; break; case "remove": @@ -1184,7 +1192,7 @@ class calendar extends rcube_plugin // unlock client $this->rc->output->command('plugin.unlock_saving'); - // update event object on the client or trigger a complete refretch if too complicated + // update event object on the client or trigger a complete refresh if too complicated if ($reload) { $args = array('source' => $event['calendar']); if ($reload > 1) @@ -1993,12 +2001,31 @@ class calendar extends rcube_plugin // start/end is all we need for 'move' action (#1480) if ($action == 'move') { - return; + return true; } // convert the submitted recurrence settings if (is_array($event['recurrence'])) { $event['recurrence'] = $this->lib->from_client_recurrence($event['recurrence'], $event['start']); + + // align start date with the first occurrence + if (!empty($event['recurrence']) && !empty($event['syncstart']) + && (empty($event['_savemode']) || $event['_savemode'] == 'all') + ) { + $next = $this->find_first_occurrence($event); + + if (!$next) { + $this->rc->output->show_message('calendar.recurrenceerror', 'error'); + return false; + } + else if ($event['start'] != $next) { + $diff = $event['start']->diff($event['end'], true); + + $event['start'] = $next; + $event['end'] = clone $next; + $event['end']->add($diff); + } + } } // convert the submitted alarm values @@ -2075,6 +2102,8 @@ class calendar extends rcube_plugin $event['url'] = $event['vurl']; unset($event['vurl']); } + + return true; } /** @@ -3447,6 +3476,35 @@ class calendar extends rcube_plugin return $this->driver->user_delete($args); } + /** + * Find first occurrence of a recurring event excluding start date + * + * @param array $event Event data (with 'start' and 'recurrence') + * + * @return DateTime Date of the first occurrence + */ + public function find_first_occurrence($event) + { + // Make sure libkolab plugin is loaded in case of Kolab driver + $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); + + $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); + } + + return $recurrence->first_occurrence(); + } + /** * Magic getter for public access to protected members */ diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index 44cfd84e..73bb81b7 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -678,7 +678,7 @@ function rcube_calendar_ui(settings) var freebusy = $('#edit-free-busy').val(event.free_busy); var priority = $('#edit-priority').val(event.priority); var sensitivity = $('#edit-sensitivity').val(event.sensitivity); - + var syncstart = $('#edit-recurrence-syncstart input'); var duration = Math.round((event.end.getTime() - event.start.getTime()) / 1000); var startdate = $('#edit-startdate').val($.fullCalendar.formatDate(event.start, settings['date_format'])).data('duration', duration); var starttime = $('#edit-starttime').val($.fullCalendar.formatDate(event.start, settings['time_format'])).show(); @@ -898,6 +898,9 @@ function rcube_calendar_ui(settings) data._fromcalendar = event.calendar; } + if (data.recurrence && syncstart.is(':checked')) + data.syncstart = 1; + update_event(action, data); $dialog.dialog("close"); } // end click: @@ -3974,8 +3977,15 @@ function rcube_calendar_ui(settings) $('#edit-attendees-form .attendees-invitebox').show(); } } + // reset autocompletion on tab change (#3389) rcmail.ksearch_blur(); + + // display recurrence warning in recurrence tab only + if (tab == 'recurrence') + $('#edit-recurrence-frequency').change(); + else + $('#edit-recurrence-syncstart').hide(); } }); $('#edit-enddate').datepicker(datepicker_settings); diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index 11ec1f41..236f1fd7 100644 --- a/plugins/calendar/drivers/kolab/kolab_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -659,14 +659,7 @@ class kolab_calendar extends kolab_storage_folder_api } // use libkolab to compute recurring events - if (class_exists('kolabcalendaring')) { - $recurrence = new kolab_date_recurrence($object); - } - else { - // fallback to local recurrence implementation - require_once($this->cal->home . '/lib/calendar_recurrence.php'); - $recurrence = new calendar_recurrence($this->cal, $event); - } + $recurrence = new kolab_date_recurrence($object); $i = 0; while ($next_event = $recurrence->next_instance()) { @@ -717,7 +710,7 @@ class kolab_calendar extends kolab_storage_folder_api if (++$i > 100000) break; } - + return $events; } diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 5bd73897..31aa47ee 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1784,15 +1784,15 @@ class kolab_driver extends calendar_driver */ private function get_recurrence_count($event, $dtstart) { + // load the given event data into a libkolabxml container + if (!$event['_formatobj']) { + $event_xml = new kolab_format_event(); + $event_xml->set($event); + $event['_formatobj'] = $event_xml; + } + // use libkolab to compute recurring events - if (class_exists('kolabcalendaring') && $event['_formatobj']) { - $recurrence = new kolab_date_recurrence($event['_formatobj']); - } - else { - // fallback to local recurrence implementation - require_once($this->cal->home . '/lib/calendar_recurrence.php'); - $recurrence = new calendar_recurrence($this->cal, $event); - } + $recurrence = new kolab_date_recurrence($event['_formatobj']); $count = 0; while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { diff --git a/plugins/calendar/lib/calendar_ui.php b/plugins/calendar/lib/calendar_ui.php index 5152c287..bf45bee9 100644 --- a/plugins/calendar/lib/calendar_ui.php +++ b/plugins/calendar/lib/calendar_ui.php @@ -92,6 +92,7 @@ class calendar_ui $this->cal->register_handler('plugin.resource_calendar', array($this, 'resource_calendar')); $this->cal->register_handler('plugin.attendees_freebusy_table', array($this, 'attendees_freebusy_table')); $this->cal->register_handler('plugin.edit_attendees_notify', array($this, 'edit_attendees_notify')); + $this->cal->register_handler('plugin.edit_recurrence_sync', array($this, 'edit_recurrence_sync')); $this->cal->register_handler('plugin.edit_recurring_warning', array($this, 'recurring_event_warning')); $this->cal->register_handler('plugin.event_rsvp_buttons', array($this, 'event_rsvp_buttons')); $this->cal->register_handler('plugin.angenda_options', array($this, 'angenda_options')); @@ -473,7 +474,7 @@ class calendar_ui } /** - * + * Render HTML for attendee notification warning */ function edit_attendees_notify($attrib = array()) { @@ -481,6 +482,15 @@ class calendar_ui return html::div($attrib, html::label(null, $checkbox->show(1) . ' ' . $this->cal->gettext('sendnotifications'))); } + /** + * Render HTML for recurrence option to align start date with the recurrence rule + */ + function edit_recurrence_sync($attrib = array()) + { + $checkbox = new html_checkbox(array('name' => '_start_sync', 'value' => 1)); + return html::div($attrib, html::label(null, $checkbox->show(1) . ' ' . $this->cal->gettext('eventstartsync'))); + } + /** * Generate the form for recurrence settings */ diff --git a/plugins/calendar/localization/en_US.inc b/plugins/calendar/localization/en_US.inc index ebbd3e47..a640c651 100644 --- a/plugins/calendar/localization/en_US.inc +++ b/plugins/calendar/localization/en_US.inc @@ -125,6 +125,7 @@ $labels['invitationspending'] = 'Pending invitations'; $labels['invitationsdeclined'] = 'Declined invitations'; $labels['changepartstat'] = 'Change participant status'; $labels['rsvpcomment'] = 'Invitation text'; +$labels['eventstartsync'] = 'Move the event start date to the first occurrence'; // agenda view $labels['listrange'] = 'Range to display:'; @@ -267,6 +268,7 @@ $labels['currentevent'] = 'Current'; $labels['futurevents'] = 'Future'; $labels['allevents'] = 'All'; $labels['saveasnew'] = 'Save as new'; +$labels['recurrenceerror'] = 'Unable to resolve recurrence rule for specified start date.'; // birthdays calendar $labels['birthdays'] = 'Birthdays'; diff --git a/plugins/calendar/skins/larry/templates/eventedit.html b/plugins/calendar/skins/larry/templates/eventedit.html index 42c20080..5f38abbb 100644 --- a/plugins/calendar/skins/larry/templates/eventedit.html +++ b/plugins/calendar/skins/larry/templates/eventedit.html @@ -127,6 +127,7 @@ +
diff --git a/plugins/libcalendaring/lib/libcalendaring_recurrence.php b/plugins/libcalendaring/lib/libcalendaring_recurrence.php index bbc4976c..f83d024c 100644 --- a/plugins/libcalendaring/lib/libcalendaring_recurrence.php +++ b/plugins/libcalendaring/lib/libcalendaring_recurrence.php @@ -152,4 +152,83 @@ class libcalendaring_recurrence return $last; } + /** + * Find date/time of the first occurrence (excluding start date) + */ + public function first_occurrence() + { + $start = clone $this->start; + $orig_start = clone $this->start; + $r = $this->recurrence; + $interval = intval($r['INTERVAL'] ?: 1); + + switch ($this->recurrence['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; + + default: + return $start; + } + + $r = $this->recurrence; + $r['INTERVAL'] = $interval; + if ($r['COUNT']) { + // Increase count so we do not stop the loop to early + $r['COUNT'] += 100; + } + + // Create recurrence that starts in the past + $recurrence = new self($this->lib); + $recurrence->init($r, $start); + + // find the first occurrence + $found = false; + while ($next = $recurrence->next()) { + $start = $next; + if ($next >= $orig_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); + + return null; + } + + if ($start Instanceof Horde_Date) { + $start = $start->toDateTime(); + } + + $start->_dateonly = $this->dateonly; + + return $start; + } } diff --git a/plugins/libkolab/lib/kolab_date_recurrence.php b/plugins/libkolab/lib/kolab_date_recurrence.php index 2f92c07c..a9a04cbc 100644 --- a/plugins/libkolab/lib/kolab_date_recurrence.php +++ b/plugins/libkolab/lib/kolab_date_recurrence.php @@ -138,4 +138,89 @@ class kolab_date_recurrence return false; } + + /** + * Find date/time of the first occurrence (excluding start date) + */ + 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); + + // find the first occurrence + $found = false; + while ($next = $recurrence->next_start()) { + $start = $next; + if ($next >= $orig_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($event['recurrence'])), + ), true); + + return null; + } + + if ($orig_start->_dateonly) { + $start->_dateonly = true; + } + + return $start; + } } diff --git a/plugins/libkolab/tests/kolab_date_recurrence.php b/plugins/libkolab/tests/kolab_date_recurrence.php new file mode 100644 index 00000000..8fd57b2c --- /dev/null +++ b/plugins/libkolab/tests/kolab_date_recurrence.php @@ -0,0 +1,213 @@ + + * + * Copyright (C) 2017, Kolab Systems AG