1<?php
2
3/**
4 * Calendar plugin for Roundcube webmail
5 *
6 * @author Lazlo Westerhof <hello@lazlo.me>
7 * @author Thomas Bruederli <bruederli@kolabsys.com>
8 *
9 * Copyright (C) 2010, Lazlo Westerhof <hello@lazlo.me>
10 * Copyright (C) 2014-2015, Kolab Systems AG <contact@kolabsys.com>
11 *
12 * This program is free software: you can redistribute it and/or modify
13 * it under the terms of the GNU Affero General Public License as
14 * published by the Free Software Foundation, either version 3 of the
15 * License, or (at your option) any later version.
16 *
17 * This program is distributed in the hope that it will be useful,
18 * but WITHOUT ANY WARRANTY; without even the implied warranty of
19 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 * GNU Affero General Public License for more details.
21 *
22 * You should have received a copy of the GNU Affero General Public License
23 * along with this program. If not, see <http://www.gnu.org/licenses/>.
24 */
25
26class calendar extends rcube_plugin
27{
28  const FREEBUSY_UNKNOWN = 0;
29  const FREEBUSY_FREE = 1;
30  const FREEBUSY_BUSY = 2;
31  const FREEBUSY_TENTATIVE = 3;
32  const FREEBUSY_OOF = 4;
33
34  const SESSION_KEY = 'calendar_temp';
35
36  public $task = '?(?!logout).*';
37  public $rc;
38  public $lib;
39  public $resources_dir;
40  public $home;  // declare public to be used in other classes
41  public $urlbase;
42  public $timezone;
43  public $timezone_offset;
44  public $gmt_offset;
45  public $ui;
46
47  public $defaults = array(
48    'calendar_default_view' => "agendaWeek",
49    'calendar_timeslots'    => 2,
50    'calendar_work_start'   => 6,
51    'calendar_work_end'     => 18,
52    'calendar_agenda_range' => 60,
53    'calendar_event_coloring'  => 0,
54    'calendar_time_indicator'  => true,
55    'calendar_allow_invite_shared' => false,
56    'calendar_itip_send_option'    => 3,
57    'calendar_itip_after_action'   => 0,
58  );
59
60// These are implemented with __get()
61//  private $ical;
62//  private $itip;
63//  private $driver;
64
65
66  /**
67   * Plugin initialization.
68   */
69  function init()
70  {
71    $this->rc = rcube::get_instance();
72
73    $this->register_task('calendar', 'calendar');
74
75    // load calendar configuration
76    $this->load_config();
77
78    // catch iTIP confirmation requests that don're require a valid session
79    if ($this->rc->action == 'attend' && !empty($_REQUEST['_t'])) {
80      $this->add_hook('startup', array($this, 'itip_attend_response'));
81    }
82    else if ($this->rc->action == 'feed' && !empty($_REQUEST['_cal'])) {
83      $this->add_hook('startup', array($this, 'ical_feed_export'));
84    }
85    else if ($this->rc->task != 'login') {
86      // default startup routine
87      $this->add_hook('startup', array($this, 'startup'));
88    }
89
90    $this->add_hook('user_delete', array($this, 'user_delete'));
91  }
92
93  /**
94   * Setup basic plugin environment and UI
95   */
96  protected function setup()
97  {
98    $this->require_plugin('libcalendaring');
99    $this->require_plugin('libkolab');
100
101    $this->lib             = libcalendaring::get_instance();
102    $this->timezone        = $this->lib->timezone;
103    $this->gmt_offset      = $this->lib->gmt_offset;
104    $this->dst_active      = $this->lib->dst_active;
105    $this->timezone_offset = $this->gmt_offset / 3600 - $this->dst_active;
106
107    // load localizations
108    $this->add_texts('localization/', $this->rc->task == 'calendar' && (!$this->rc->action || $this->rc->action == 'print'));
109
110    require($this->home . '/lib/calendar_ui.php');
111    $this->ui = new calendar_ui($this);
112  }
113
114  /**
115   * Startup hook
116   */
117  public function startup($args)
118  {
119    // the calendar module can be enabled/disabled by the kolab_auth plugin
120    if ($this->rc->config->get('calendar_disabled', false) || !$this->rc->config->get('calendar_enabled', true))
121        return;
122
123    $this->setup();
124
125    // load Calendar user interface
126    if (!$this->rc->output->ajax_call && (!$this->rc->output->env['framed'] || $args['action'] == 'preview')) {
127      $this->ui->init();
128
129      // settings are required in (almost) every GUI step
130      if ($args['action'] != 'attend')
131        $this->rc->output->set_env('calendar_settings', $this->load_settings());
132    }
133
134    if ($args['task'] == 'calendar' && $args['action'] != 'save-pref') {
135      if ($args['action'] != 'upload') {
136        $this->load_driver();
137      }
138
139      // register calendar actions
140      $this->register_action('index', array($this, 'calendar_view'));
141      $this->register_action('event', array($this, 'event_action'));
142      $this->register_action('calendar', array($this, 'calendar_action'));
143      $this->register_action('count', array($this, 'count_events'));
144      $this->register_action('load_events', array($this, 'load_events'));
145      $this->register_action('export_events', array($this, 'export_events'));
146      $this->register_action('import_events', array($this, 'import_events'));
147      $this->register_action('upload', array($this, 'attachment_upload'));
148      $this->register_action('get-attachment', array($this, 'attachment_get'));
149      $this->register_action('freebusy-status', array($this, 'freebusy_status'));
150      $this->register_action('freebusy-times', array($this, 'freebusy_times'));
151      $this->register_action('randomdata', array($this, 'generate_randomdata'));
152      $this->register_action('print', array($this,'print_view'));
153      $this->register_action('mailimportitip', array($this, 'mail_import_itip'));
154      $this->register_action('mailimportattach', array($this, 'mail_import_attachment'));
155      $this->register_action('dialog-ui', array($this, 'mail_message2event'));
156      $this->register_action('check-recent', array($this, 'check_recent'));
157      $this->register_action('itip-status', array($this, 'event_itip_status'));
158      $this->register_action('itip-remove', array($this, 'event_itip_remove'));
159      $this->register_action('itip-decline-reply', array($this, 'mail_itip_decline_reply'));
160      $this->register_action('itip-delegate', array($this, 'mail_itip_delegate'));
161      $this->register_action('resources-list', array($this, 'resources_list'));
162      $this->register_action('resources-owner', array($this, 'resources_owner'));
163      $this->register_action('resources-calendar', array($this, 'resources_calendar'));
164      $this->register_action('resources-autocomplete', array($this, 'resources_autocomplete'));
165      $this->add_hook('refresh', array($this, 'refresh'));
166
167      // remove undo information...
168      if ($undo = $_SESSION['calendar_event_undo']) {
169        // ...after timeout
170        $undo_time = $this->rc->config->get('undo_timeout', 0);
171        if ($undo['ts'] < time() - $undo_time) {
172          $this->rc->session->remove('calendar_event_undo');
173          // @TODO: do EXPUNGE on kolab objects?
174        }
175      }
176    }
177    else if ($args['task'] == 'settings') {
178      // add hooks for Calendar settings
179      $this->add_hook('preferences_sections_list', array($this, 'preferences_sections_list'));
180      $this->add_hook('preferences_list', array($this, 'preferences_list'));
181      $this->add_hook('preferences_save', array($this, 'preferences_save'));
182    }
183    else if ($args['task'] == 'mail') {
184      // hooks to catch event invitations on incoming mails
185      if ($args['action'] == 'show' || $args['action'] == 'preview') {
186        $this->add_hook('template_object_messagebody', array($this, 'mail_messagebody_html'));
187      }
188
189      // add 'Create event' item to message menu
190      if ($this->api->output->type == 'html' && $_GET['_rel'] != 'event') {
191        $this->api->add_content(html::tag('li', array('role' => 'menuitem'),
192          $this->api->output->button(array(
193            'command'  => 'calendar-create-from-mail',
194            'label'    => 'calendar.createfrommail',
195            'type'     => 'link',
196            'classact' => 'icon calendarlink active',
197            'class'    => 'icon calendarlink disabled',
198            'innerclass' => 'icon calendar',
199          ))),
200          'messagemenu');
201
202        $this->api->output->add_label('calendar.createfrommail');
203      }
204
205      $this->add_hook('messages_list', array($this, 'mail_messages_list'));
206      $this->add_hook('message_compose', array($this, 'mail_message_compose'));
207    }
208    else if ($args['task'] == 'addressbook') {
209      if ($this->rc->config->get('calendar_contact_birthdays')) {
210        $this->add_hook('contact_update', array($this, 'contact_update'));
211        $this->add_hook('contact_create', array($this, 'contact_update'));
212      }
213    }
214
215    // add hooks to display alarms
216    $this->add_hook('pending_alarms', array($this, 'pending_alarms'));
217    $this->add_hook('dismiss_alarms', array($this, 'dismiss_alarms'));
218  }
219
220  /**
221   * Helper method to load the backend driver according to local config
222   */
223  private function load_driver()
224  {
225    if (is_object($this->driver))
226      return;
227
228    $driver_name = $this->rc->config->get('calendar_driver', 'database');
229    $driver_class = $driver_name . '_driver';
230
231    require_once($this->home . '/drivers/calendar_driver.php');
232    require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php');
233
234    $this->driver = new $driver_class($this);
235
236    if ($this->driver->undelete)
237      $this->driver->undelete = $this->rc->config->get('undo_timeout', 0) > 0;
238  }
239
240  /**
241   * Load iTIP functions
242   */
243  private function load_itip()
244  {
245    if (!$this->itip) {
246      require_once($this->home . '/lib/calendar_itip.php');
247      $this->itip = new calendar_itip($this);
248
249      if ($this->rc->config->get('kolab_invitation_calendars'))
250        $this->itip->set_rsvp_actions(array('accepted','tentative','declined','delegated','needs-action'));
251    }
252
253    return $this->itip;
254  }
255
256  /**
257   * Load iCalendar functions
258   */
259  public function get_ical()
260  {
261    if (!$this->ical) {
262      $this->ical = libcalendaring::get_ical();
263    }
264
265    return $this->ical;
266  }
267
268  /**
269   * Get properties of the calendar this user has specified as default
270   */
271  public function get_default_calendar($sensitivity = null, $calendars = null)
272  {
273    if ($calendars === null) {
274      $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL | calendar_driver::FILTER_WRITEABLE);
275    }
276
277    $default_id = $this->rc->config->get('calendar_default_calendar');
278    $calendar   = $calendars[$default_id] ?: null;
279
280    if (!$calendar || $sensitivity) {
281      foreach ($calendars as $cal) {
282        if ($sensitivity && $cal['subtype'] == $sensitivity) {
283          $calendar = $cal;
284          break;
285        }
286        if ($cal['default'] && $cal['editable']) {
287          $calendar = $cal;
288        }
289        if ($cal['editable']) {
290          $first = $cal;
291        }
292      }
293    }
294
295    return $calendar ?: $first;
296  }
297
298  /**
299   * Render the main calendar view from skin template
300   */
301  function calendar_view()
302  {
303    $this->rc->output->set_pagetitle($this->gettext('calendar'));
304
305    // Add JS files to the page header
306    $this->ui->addJS();
307
308    $this->ui->init_templates();
309    $this->rc->output->add_label('lowest','low','normal','high','highest','delete','cancel','uploading','noemailwarning','close');
310
311    // initialize attendees autocompletion
312    $this->rc->autocomplete_init();
313
314    $this->rc->output->set_env('timezone', $this->timezone->getName());
315    $this->rc->output->set_env('calendar_driver', $this->rc->config->get('calendar_driver'), false);
316    $this->rc->output->set_env('calendar_resources', (bool)$this->rc->config->get('calendar_resources_driver'));
317    $this->rc->output->set_env('identities-selector', $this->ui->identity_select(array(
318        'id'         => 'edit-identities-list',
319        'aria-label' => $this->gettext('roleorganizer'),
320        'class'      => 'form-control custom-select',
321    )));
322
323    $view = rcube_utils::get_input_value('view', rcube_utils::INPUT_GPC);
324    if (in_array($view, array('agendaWeek', 'agendaDay', 'month', 'list')))
325      $this->rc->output->set_env('view', $view);
326
327    if ($date = rcube_utils::get_input_value('date', rcube_utils::INPUT_GPC))
328      $this->rc->output->set_env('date', $date);
329
330    if ($msgref = rcube_utils::get_input_value('itip', rcube_utils::INPUT_GPC))
331      $this->rc->output->set_env('itip_events', $this->itip_events($msgref));
332
333    $this->rc->output->send("calendar.calendar");
334  }
335
336  /**
337   * Handler for preferences_sections_list hook.
338   * Adds Calendar settings sections into preferences sections list.
339   *
340   * @param array Original parameters
341   * @return array Modified parameters
342   */
343  function preferences_sections_list($p)
344  {
345    $p['list']['calendar'] = array(
346      'id' => 'calendar', 'section' => $this->gettext('calendar'),
347    );
348
349    return $p;
350  }
351
352  /**
353   * Handler for preferences_list hook.
354   * Adds options blocks into Calendar settings sections in Preferences.
355   *
356   * @param array Original parameters
357   * @return array Modified parameters
358   */
359  function preferences_list($p)
360  {
361    if ($p['section'] != 'calendar') {
362      return $p;
363    }
364
365    $no_override = array_flip((array)$this->rc->config->get('dont_override'));
366
367    $p['blocks']['view']['name'] = $this->gettext('mainoptions');
368
369    if (!isset($no_override['calendar_default_view'])) {
370      if (!$p['current']) {
371        $p['blocks']['view']['content'] = true;
372        return $p;
373      }
374
375      $field_id = 'rcmfd_default_view';
376      $view = $this->rc->config->get('calendar_default_view', $this->defaults['calendar_default_view']);
377      $select = new html_select(array('name' => '_default_view', 'id' => $field_id));
378      $select->add($this->gettext('day'), "agendaDay");
379      $select->add($this->gettext('week'), "agendaWeek");
380      $select->add($this->gettext('month'), "month");
381      $select->add($this->gettext('agenda'), "list");
382      $p['blocks']['view']['options']['default_view'] = array(
383        'title' => html::label($field_id, rcube::Q($this->gettext('default_view'))),
384        'content' => $select->show($view == 'table' ? 'list' : $view),
385      );
386    }
387
388    if (!isset($no_override['calendar_timeslots'])) {
389      if (!$p['current']) {
390        $p['blocks']['view']['content'] = true;
391        return $p;
392      }
393
394      $field_id = 'rcmfd_timeslot';
395      $choices = array('1', '2', '3', '4', '6');
396      $select = new html_select(array('name' => '_timeslots', 'id' => $field_id));
397      $select->add($choices);
398      $p['blocks']['view']['options']['timeslots'] = array(
399        'title' => html::label($field_id, rcube::Q($this->gettext('timeslots'))),
400        'content' => $select->show(strval($this->rc->config->get('calendar_timeslots', $this->defaults['calendar_timeslots']))),
401      );
402    }
403
404    if (!isset($no_override['calendar_first_day'])) {
405      if (!$p['current']) {
406        $p['blocks']['view']['content'] = true;
407        return $p;
408      }
409
410      $field_id = 'rcmfd_firstday';
411      $select = new html_select(array('name' => '_first_day', 'id' => $field_id));
412      $select->add($this->gettext('sunday'), '0');
413      $select->add($this->gettext('monday'), '1');
414      $select->add($this->gettext('tuesday'), '2');
415      $select->add($this->gettext('wednesday'), '3');
416      $select->add($this->gettext('thursday'), '4');
417      $select->add($this->gettext('friday'), '5');
418      $select->add($this->gettext('saturday'), '6');
419      $p['blocks']['view']['options']['first_day'] = array(
420        'title' => html::label($field_id, rcube::Q($this->gettext('first_day'))),
421        'content' => $select->show(strval($this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']))),
422      );
423    }
424
425    if (!isset($no_override['calendar_first_hour'])) {
426      if (!$p['current']) {
427        $p['blocks']['view']['content'] = true;
428        return $p;
429      }
430
431      $time_format = $this->rc->config->get('time_format', libcalendaring::to_php_date_format($this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format'])));
432      $select_hours = new html_select();
433      for ($h = 0; $h < 24; $h++)
434        $select_hours->add(date($time_format, mktime($h, 0, 0)), $h);
435
436      $field_id = 'rcmfd_firsthour';
437      $p['blocks']['view']['options']['first_hour'] = array(
438        'title' => html::label($field_id, rcube::Q($this->gettext('first_hour'))),
439        'content' => $select_hours->show($this->rc->config->get('calendar_first_hour', $this->defaults['calendar_first_hour']), array('name' => '_first_hour', 'id' => $field_id)),
440      );
441    }
442
443    if (!isset($no_override['calendar_work_start'])) {
444      if (!$p['current']) {
445        $p['blocks']['view']['content'] = true;
446        return $p;
447      }
448
449      $field_id   = 'rcmfd_workstart';
450      $work_start = $this->rc->config->get('calendar_work_start', $this->defaults['calendar_work_start']);
451      $work_end   = $this->rc->config->get('calendar_work_end', $this->defaults['calendar_work_end']);
452      $p['blocks']['view']['options']['workinghours'] = array(
453        'title'   => html::label($field_id, rcube::Q($this->gettext('workinghours'))),
454        'content' => html::div('input-group',
455          $select_hours->show($work_start, array('name' => '_work_start', 'id' => $field_id))
456          . html::span('input-group-append input-group-prepend', html::span('input-group-text',' &mdash; '))
457          . $select_hours->show($work_end, array('name' => '_work_end', 'id' => $field_id))
458        )
459      );
460    }
461
462    if (!isset($no_override['calendar_event_coloring'])) {
463      if (!$p['current']) {
464        $p['blocks']['view']['content'] = true;
465        return $p;
466      }
467
468      $field_id = 'rcmfd_coloring';
469      $select_colors = new html_select(array('name' => '_event_coloring', 'id' => $field_id));
470      $select_colors->add($this->gettext('coloringmode0'), 0);
471      $select_colors->add($this->gettext('coloringmode1'), 1);
472      $select_colors->add($this->gettext('coloringmode2'), 2);
473      $select_colors->add($this->gettext('coloringmode3'), 3);
474
475      $p['blocks']['view']['options']['eventcolors'] = array(
476        'title'   => html::label($field_id, rcube::Q($this->gettext('eventcoloring'))),
477        'content' => $select_colors->show($this->rc->config->get('calendar_event_coloring', $this->defaults['calendar_event_coloring'])),
478      );
479    }
480
481    // loading driver is expensive, don't do it if not needed
482    $this->load_driver();
483
484    if (!isset($no_override['calendar_default_alarm_type']) || !isset($no_override['calendar_default_alarm_offset'])) {
485      if (!$p['current']) {
486        $p['blocks']['view']['content'] = true;
487        return $p;
488      }
489
490      $alarm_type = $alarm_offset = '';
491
492      if (!isset($no_override['calendar_default_alarm_type'])) {
493        $field_id    = 'rcmfd_alarm';
494        $select_type = new html_select(array('name' => '_alarm_type', 'id' => $field_id));
495        $select_type->add($this->gettext('none'), '');
496
497        foreach ($this->driver->alarm_types as $type) {
498          $select_type->add($this->rc->gettext(strtolower("alarm{$type}option"), 'libcalendaring'), $type);
499        }
500
501        $alarm_type = $select_type->show($this->rc->config->get('calendar_default_alarm_type', ''));
502      }
503
504      if (!isset($no_override['calendar_default_alarm_offset'])) {
505        $field_id      = 'rcmfd_alarm';
506        $input_value   = new html_inputfield(array('name' => '_alarm_value', 'id' => $field_id . 'value', 'size' => 3));
507        $select_offset = new html_select(array('name' => '_alarm_offset', 'id' => $field_id . 'offset'));
508
509        foreach (array('-M','-H','-D','+M','+H','+D') as $trigger) {
510          $select_offset->add($this->rc->gettext('trigger' . $trigger, 'libcalendaring'), $trigger);
511        }
512
513        $preset = libcalendaring::parse_alarm_value($this->rc->config->get('calendar_default_alarm_offset', '-15M'));
514        $alarm_offset = $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1]);
515      }
516
517      $p['blocks']['view']['options']['alarmtype'] = array(
518        'title' => html::label($field_id, rcube::Q($this->gettext('defaultalarmtype'))),
519        'content' => html::div('input-group', $alarm_type . ' ' . $alarm_offset),
520      );
521    }
522
523    if (!isset($no_override['calendar_default_calendar'])) {
524      if (!$p['current']) {
525        $p['blocks']['view']['content'] = true;
526        return $p;
527      }
528      // default calendar selection
529      $field_id   = 'rcmfd_default_calendar';
530      $filter     = calendar_driver::FILTER_PERSONAL | calendar_driver::FILTER_ACTIVE | calendar_driver::FILTER_INSERTABLE;
531      $select_cal = new html_select(array('name' => '_default_calendar', 'id' => $field_id, 'is_escaped' => true));
532      foreach ((array)$this->driver->list_calendars($filter) as $id => $prop) {
533        $select_cal->add($prop['name'], strval($id));
534        if ($prop['default'])
535          $default_calendar = $id;
536      }
537      $p['blocks']['view']['options']['defaultcalendar'] = array(
538        'title' => html::label($field_id, rcube::Q($this->gettext('defaultcalendar'))),
539        'content' => $select_cal->show($this->rc->config->get('calendar_default_calendar', $default_calendar)),
540      );
541    }
542
543    if (!isset($no_override['calendar_show_weekno'])) {
544      if (!$p['current']) {
545        $p['blocks']['view']['content'] = true;
546        return $p;
547      }
548
549      $field_id   = 'rcmfd_show_weekno';
550      $select = new html_select(array('name' => '_show_weekno', 'id' => $field_id));
551      $select->add($this->gettext('weeknonone'), -1);
552      $select->add($this->gettext('weeknodatepicker'), 0);
553      $select->add($this->gettext('weeknoall'), 1);
554
555      $p['blocks']['view']['options']['show_weekno'] = array(
556        'title' => html::label($field_id, rcube::Q($this->gettext('showweekno'))),
557        'content' => $select->show(intval($this->rc->config->get('calendar_show_weekno'))),
558      );
559    }
560
561    $p['blocks']['itip']['name'] = $this->gettext('itipoptions');
562
563    // Invitations handling
564    if (!isset($no_override['calendar_itip_after_action'])) {
565      if (!$p['current']) {
566        $p['blocks']['itip']['content'] = true;
567        return $p;
568      }
569
570      $field_id = 'rcmfd_after_action';
571      $select   = new html_select(array('name' => '_after_action', 'id' => $field_id,
572        'onchange' => "\$('#{$field_id}_select')[this.value == 4 ? 'show' : 'hide']()"));
573
574      $select->add($this->gettext('afternothing'), '');
575      $select->add($this->gettext('aftertrash'), 1);
576      $select->add($this->gettext('afterdelete'), 2);
577      $select->add($this->gettext('afterflagdeleted'), 3);
578      $select->add($this->gettext('aftermoveto'), 4);
579
580      $val = $this->rc->config->get('calendar_itip_after_action', $this->defaults['calendar_itip_after_action']);
581      if ($val !== null && $val !== '' && !is_int($val)) {
582        $folder = $val;
583        $val    = 4;
584      }
585
586      $folders = $this->rc->folder_selector(array(
587          'id'            => $field_id . '_select',
588          'name'          => '_after_action_folder',
589          'maxlength'     => 30,
590          'folder_filter' => 'mail',
591          'folder_rights' => 'w',
592          'style'         => $val !== 4 ? 'display:none' : '',
593      ));
594
595      $p['blocks']['itip']['options']['after_action'] = array(
596        'title'   => html::label($field_id, rcube::Q($this->gettext('afteraction'))),
597        'content' => html::div('input-group input-group-combo', $select->show($val) . $folders->show($folder)),
598      );
599    }
600
601    // category definitions
602    if (!$this->driver->nocategories && !isset($no_override['calendar_categories'])) {
603        $p['blocks']['categories']['name'] = $this->gettext('categories');
604
605        if (!$p['current']) {
606          $p['blocks']['categories']['content'] = true;
607          return $p;
608        }
609
610        $categories = (array) $this->driver->list_categories();
611        $categories_list = '';
612        foreach ($categories as $name => $color) {
613          $key = md5($name);
614          $field_class = 'rcmfd_category_' . str_replace(' ', '_', $name);
615          $category_remove = html::span('input-group-append', html::a(array(
616              'class'   => 'button icon delete input-group-text',
617              'onclick' => '$(this).parent().parent().remove()',
618              'title'   => $this->gettext('remove_category'),
619              'href'    => '#rcmfd_new_category',
620            ), html::span('inner', $this->gettext('delete'))
621          ));
622          $category_name  = new html_inputfield(array('name' => "_categories[$key]", 'class' => $field_class, 'size' => 30, 'disabled' => $this->driver->categoriesimmutable));
623          $category_color = new html_inputfield(array('name' => "_colors[$key]", 'class' => "$field_class colors", 'size' => 6));
624          $hidden = $this->driver->categoriesimmutable ? html::tag('input', array('type' => 'hidden', 'name' => "_categories[$key]", 'value' => $name)) : '';
625          $categories_list .= $hidden . html::div('input-group', $category_name->show($name) . $category_color->show($color) . $category_remove);
626        }
627
628        $p['blocks']['categories']['options']['category_' . $name] = array(
629          'content' => html::div(array('id' => 'calendarcategories'), $categories_list),
630        );
631
632        $field_id = 'rcmfd_new_category';
633        $new_category = new html_inputfield(array('name' => '_new_category', 'id' => $field_id, 'size' => 30));
634        $add_category = html::span('input-group-append', html::a(array(
635            'type'    => 'button',
636            'class'   => 'button create input-group-text',
637            'title'   => $this->gettext('add_category'),
638            'onclick' => 'rcube_calendar_add_category()',
639            'href'    => '#rcmfd_new_category',
640          ), html::span('inner', $this->gettext('add_category'))
641        ));
642        $p['blocks']['categories']['options']['categories'] = array(
643          'content' => html::div('input-group', $new_category->show('') . $add_category),
644        );
645
646        $this->rc->output->add_label('delete', 'calendar.remove_category');
647        $this->rc->output->add_script('function rcube_calendar_add_category() {
648          var name = $("#rcmfd_new_category").val();
649          if (name.length) {
650            var button_label = rcmail.gettext("calendar.remove_category");
651            var input = $("<input>").attr({type: "text", name: "_categories[]", size: 30, "class": "form-control"}).val(name);
652            var color = $("<input>").attr({type: "text", name: "_colors[]", size: 6, "class": "colors form-control"}).val("000000");
653            var button = $("<a>").attr({"class": "button icon delete input-group-text", title: button_label, href: "#rcmfd_new_category"})
654              .click(function() { $(this).parent().parent().remove(); })
655              .append($("<span>").addClass("inner").text(rcmail.gettext("delete")));
656
657            $("<div>").addClass("input-group").append(input).append(color).append($("<span class=\'input-group-append\'>").append(button))
658              .appendTo("#calendarcategories");
659            color.minicolors(rcmail.env.minicolors_config || {});
660            $("#rcmfd_new_category").val("");
661          }
662        }', 'foot');
663
664        $this->rc->output->add_script('$("#rcmfd_new_category").keypress(function(event) {
665          if (event.which == 13) {
666            rcube_calendar_add_category();
667            event.preventDefault();
668          }
669        });
670        ', 'docready');
671
672        // load miniColors js/css files
673        jqueryui::miniColors();
674    }
675
676    // virtual birthdays calendar
677    if (!isset($no_override['calendar_contact_birthdays'])) {
678      $p['blocks']['birthdays']['name'] = $this->gettext('birthdayscalendar');
679
680      if (!$p['current']) {
681        $p['blocks']['birthdays']['content'] = true;
682        return $p;
683      }
684
685      $field_id = 'rcmfd_contact_birthdays';
686      $input    = new html_checkbox(array('name' => '_contact_birthdays', 'id' => $field_id, 'value' => 1, 'onclick' => '$(".calendar_birthday_props").prop("disabled",!this.checked)'));
687
688      $p['blocks']['birthdays']['options']['contact_birthdays'] = array(
689        'title'   => html::label($field_id, $this->gettext('displaybirthdayscalendar')),
690        'content' => $input->show($this->rc->config->get('calendar_contact_birthdays')?1:0),
691      );
692
693      $input_attrib = array(
694        'class' => 'calendar_birthday_props',
695        'disabled' => !$this->rc->config->get('calendar_contact_birthdays'),
696      );
697
698      $sources = array();
699      $checkbox = new html_checkbox(array('name' => '_birthday_adressbooks[]') + $input_attrib);
700      foreach ($this->rc->get_address_sources(false, true) as $source) {
701        $active = in_array($source['id'], (array)$this->rc->config->get('calendar_birthday_adressbooks', array())) ? $source['id'] : '';
702        $sources[] = html::tag('li', null, html::label(null, $checkbox->show($active, array('value' => $source['id'])) . rcube::Q($source['realname'] ?: $source['name'])));
703      }
704
705      $p['blocks']['birthdays']['options']['birthday_adressbooks'] = array(
706        'title'   => rcube::Q($this->gettext('birthdayscalendarsources')),
707        'content' => html::tag('ul', 'proplist', implode("\n", $sources)),
708      );
709
710      $field_id = 'rcmfd_birthdays_alarm';
711      $select_type = new html_select(array('name' => '_birthdays_alarm_type', 'id' => $field_id) + $input_attrib);
712      $select_type->add($this->gettext('none'), '');
713      foreach ($this->driver->alarm_types as $type) {
714        $select_type->add($this->rc->gettext(strtolower("alarm{$type}option"), 'libcalendaring'), $type);
715      }
716
717      $input_value = new html_inputfield(array('name' => '_birthdays_alarm_value', 'id' => $field_id . 'value', 'size' => 3) + $input_attrib);
718      $select_offset = new html_select(array('name' => '_birthdays_alarm_offset', 'id' => $field_id . 'offset') + $input_attrib);
719      foreach (array('-M','-H','-D') as $trigger)
720        $select_offset->add($this->rc->gettext('trigger' . $trigger, 'libcalendaring'), $trigger);
721
722      $preset      = libcalendaring::parse_alarm_value($this->rc->config->get('calendar_birthdays_alarm_offset', '-1D'));
723      $preset_type = $this->rc->config->get('calendar_birthdays_alarm_type', '');
724
725      $p['blocks']['birthdays']['options']['birthdays_alarmoffset'] = array(
726        'title'   => html::label($field_id, rcube::Q($this->gettext('showalarms'))),
727        'content' => html::div('input-group', $select_type->show($preset_type) . $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1])),
728      );
729    }
730
731    return $p;
732  }
733
734  /**
735   * Handler for preferences_save hook.
736   * Executed on Calendar settings form submit.
737   *
738   * @param array Original parameters
739   * @return array Modified parameters
740   */
741  function preferences_save($p)
742  {
743    if ($p['section'] == 'calendar') {
744      $this->load_driver();
745
746      // compose default alarm preset value
747      $alarm_offset  = rcube_utils::get_input_value('_alarm_offset', rcube_utils::INPUT_POST);
748      $alarm_value   = rcube_utils::get_input_value('_alarm_value', rcube_utils::INPUT_POST);
749      $default_alarm = $alarm_offset[0] . intval($alarm_value) . $alarm_offset[1];
750
751      $birthdays_alarm_offset = rcube_utils::get_input_value('_birthdays_alarm_offset', rcube_utils::INPUT_POST);
752      $birthdays_alarm_value  = rcube_utils::get_input_value('_birthdays_alarm_value', rcube_utils::INPUT_POST);
753      $birthdays_alarm_value  = $birthdays_alarm_offset[0] . intval($birthdays_alarm_value) . $birthdays_alarm_offset[1];
754
755      $p['prefs'] = array(
756        'calendar_default_view' => rcube_utils::get_input_value('_default_view', rcube_utils::INPUT_POST),
757        'calendar_timeslots'    => intval(rcube_utils::get_input_value('_timeslots', rcube_utils::INPUT_POST)),
758        'calendar_first_day'    => intval(rcube_utils::get_input_value('_first_day', rcube_utils::INPUT_POST)),
759        'calendar_first_hour'   => intval(rcube_utils::get_input_value('_first_hour', rcube_utils::INPUT_POST)),
760        'calendar_work_start'   => intval(rcube_utils::get_input_value('_work_start', rcube_utils::INPUT_POST)),
761        'calendar_work_end'     => intval(rcube_utils::get_input_value('_work_end', rcube_utils::INPUT_POST)),
762        'calendar_show_weekno'  => intval(rcube_utils::get_input_value('_show_weekno', rcube_utils::INPUT_POST)),
763        'calendar_event_coloring'       => intval(rcube_utils::get_input_value('_event_coloring', rcube_utils::INPUT_POST)),
764        'calendar_default_alarm_type'   => rcube_utils::get_input_value('_alarm_type', rcube_utils::INPUT_POST),
765        'calendar_default_alarm_offset' => $default_alarm,
766        'calendar_default_calendar'     => rcube_utils::get_input_value('_default_calendar', rcube_utils::INPUT_POST),
767        'calendar_date_format' => null,  // clear previously saved values
768        'calendar_time_format' => null,
769        'calendar_contact_birthdays'    => rcube_utils::get_input_value('_contact_birthdays', rcube_utils::INPUT_POST) ? true : false,
770        'calendar_birthday_adressbooks' => (array) rcube_utils::get_input_value('_birthday_adressbooks', rcube_utils::INPUT_POST),
771        'calendar_birthdays_alarm_type'   => rcube_utils::get_input_value('_birthdays_alarm_type', rcube_utils::INPUT_POST),
772        'calendar_birthdays_alarm_offset' => $birthdays_alarm_value ?: null,
773        'calendar_itip_after_action'      => intval(rcube_utils::get_input_value('_after_action', rcube_utils::INPUT_POST)),
774      );
775
776      if ($p['prefs']['calendar_itip_after_action'] == 4) {
777        $p['prefs']['calendar_itip_after_action'] = rcube_utils::get_input_value('_after_action_folder', rcube_utils::INPUT_POST, true);
778      }
779
780      // categories
781      if (!$this->driver->nocategories) {
782        $old_categories = $new_categories = array();
783        foreach ($this->driver->list_categories() as $name => $color) {
784          $old_categories[md5($name)] = $name;
785        }
786
787        $categories = (array) rcube_utils::get_input_value('_categories', rcube_utils::INPUT_POST);
788        $colors     = (array) rcube_utils::get_input_value('_colors', rcube_utils::INPUT_POST);
789
790        foreach ($categories as $key => $name) {
791          if (!isset($colors[$key])) {
792            continue;
793          }
794
795          $color = preg_replace('/^#/', '', strval($colors[$key]));
796
797          // rename categories in existing events -> driver's job
798          if ($oldname = $old_categories[$key]) {
799            $this->driver->replace_category($oldname, $name, $color);
800            unset($old_categories[$key]);
801          }
802          else
803            $this->driver->add_category($name, $color);
804
805          $new_categories[$name] = $color;
806        }
807
808        // these old categories have been removed, alter events accordingly -> driver's job
809        foreach ((array)$old_categories[$key] as $key => $name) {
810          $this->driver->remove_category($name);
811        }
812
813        $p['prefs']['calendar_categories'] = $new_categories;
814      }
815    }
816
817    return $p;
818  }
819
820  /**
821   * Dispatcher for calendar actions initiated by the client
822   */
823  function calendar_action()
824  {
825    $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC);
826    $cal    = rcube_utils::get_input_value('c', rcube_utils::INPUT_GPC);
827    $success = $reload = false;
828
829    if (isset($cal['showalarms']))
830      $cal['showalarms'] = intval($cal['showalarms']);
831
832    switch ($action) {
833      case "form-new":
834      case "form-edit":
835        echo $this->ui->calendar_editform($action, $cal);
836        exit;
837      case "new":
838        $success = $this->driver->create_calendar($cal);
839        $reload = true;
840        break;
841      case "edit":
842        $success = $this->driver->edit_calendar($cal);
843        $reload = true;
844        break;
845      case "delete":
846        if ($success = $this->driver->delete_calendar($cal))
847          $this->rc->output->command('plugin.destroy_source', array('id' => $cal['id']));
848        break;
849      case "subscribe":
850        if (!$this->driver->subscribe_calendar($cal))
851          $this->rc->output->show_message($this->gettext('errorsaving'), 'error');
852        else {
853          $calendars = $this->driver->list_calendars();
854          $calendar  = $calendars[$cal['id']];
855
856          // find parent folder and check if it's a "user calendar"
857          // if it's also activated we need to refresh it (#5340)
858          while ($calendar['parent']) {
859            if (isset($calendars[$calendar['parent']]))
860              $calendar = $calendars[$calendar['parent']];
861            else
862              break;
863          }
864
865          if ($calendar['id'] != $cal['id'] && $calendar['active'] && $calendar['group'] == "other user")
866            $this->rc->output->command('plugin.refresh_source', $calendar['id']);
867        }
868        return;
869      case "search":
870        $results    = array();
871        $color_mode = $this->rc->config->get('calendar_event_coloring', $this->defaults['calendar_event_coloring']);
872        $query      = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC);
873        $source     = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC);
874
875        foreach ((array) $this->driver->search_calendars($query, $source) as $id => $prop) {
876          $editname = $prop['editname'];
877          unset($prop['editname']);  // force full name to be displayed
878          $prop['active'] = false;
879
880          // let the UI generate HTML and CSS representation for this calendar
881          $html = $this->ui->calendar_list_item($id, $prop, $jsenv);
882          $cal = $jsenv[$id];
883          $cal['editname'] = $editname;
884          $cal['html'] = $html;
885          if (!empty($prop['color']))
886            $cal['css'] = $this->ui->calendar_css_classes($id, $prop, $color_mode);
887
888          $results[] = $cal;
889        }
890        // report more results available
891        if ($this->driver->search_more_results)
892          $this->rc->output->show_message('autocompletemore', 'notice');
893
894        $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC));
895        return;
896    }
897
898    if ($success)
899      $this->rc->output->show_message('successfullysaved', 'confirmation');
900    else {
901      $error_msg = $this->gettext('errorsaving') . ($this->driver->last_error ? ': ' . $this->driver->last_error :'');
902      $this->rc->output->show_message($error_msg, 'error');
903    }
904
905    $this->rc->output->command('plugin.unlock_saving');
906
907    if ($success && $reload)
908      $this->rc->output->command('plugin.reload_view');
909  }
910
911
912  /**
913   * Dispatcher for event actions initiated by the client
914   */
915  function event_action()
916  {
917    $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC);
918    $event  = rcube_utils::get_input_value('e', rcube_utils::INPUT_POST, true);
919    $success = $reload = $got_msg = false;
920
921    // read old event data in order to find changes
922    if (($event['_notify'] || $event['_decline']) && $action != 'new') {
923      $old = $this->driver->get_event($event);
924
925      // load main event if savemode is 'all' or if deleting 'future' events
926      if (($event['_savemode'] == 'all' || ($event['_savemode'] == 'future' && $action == 'remove' && !$event['_decline'])) && $old['recurrence_id']) {
927        $old['id'] = $old['recurrence_id'];
928        $old = $this->driver->get_event($old);
929      }
930    }
931
932    switch ($action) {
933      case "new":
934        // create UID for new event
935        $event['uid'] = $this->generate_uid();
936        if (!$this->write_preprocess($event, $action)) {
937          $got_msg = true;
938        }
939        else if ($success = $this->driver->new_event($event)) {
940          $event['id'] = $event['uid'];
941          $event['_savemode'] = 'all';
942          $this->cleanup_event($event);
943          $this->event_save_success($event, null, $action, true);
944        }
945        $reload = $success && $event['recurrence'] ? 2 : 1;
946        break;
947
948      case "edit":
949        if (!$this->write_preprocess($event, $action)) {
950          $got_msg = true;
951        }
952        else if ($success = $this->driver->edit_event($event)) {
953          $this->cleanup_event($event);
954          $this->event_save_success($event, $old, $action, $success);
955        }
956        $reload = $success && ($event['recurrence'] || $event['_savemode'] || $event['_fromcalendar']) ? 2 : 1;
957        break;
958
959      case "resize":
960        if (!$this->write_preprocess($event, $action)) {
961          $got_msg = true;
962        }
963        else if ($success = $this->driver->resize_event($event)) {
964          $this->event_save_success($event, $old, $action, $success);
965        }
966        $reload = $event['_savemode'] ? 2 : 1;
967        break;
968
969      case "move":
970        if (!$this->write_preprocess($event, $action)) {
971          $got_msg = true;
972        }
973        else if ($success = $this->driver->move_event($event)) {
974          $this->event_save_success($event, $old, $action, $success);
975        }
976        $reload = $success && $event['_savemode'] ? 2 : 1;
977        break;
978
979      case "remove":
980        // remove previous deletes
981        $undo_time = $this->driver->undelete ? $this->rc->config->get('undo_timeout', 0) : 0;
982
983        // search for event if only UID is given
984        if (!isset($event['calendar']) && $event['uid']) {
985          if (!($event = $this->driver->get_event($event, calendar_driver::FILTER_WRITEABLE))) {
986            break;
987          }
988          $undo_time = 0;
989        }
990
991        // Note: the driver is responsible for setting $_SESSION['calendar_event_undo']
992        //       containing 'ts' and 'data' elements
993        $success = $this->driver->remove_event($event, $undo_time < 1);
994        $reload = (!$success || $event['_savemode']) ? 2 : 1;
995
996        if ($undo_time > 0 && $success) {
997          // display message with Undo link.
998          $msg = html::span(null, $this->gettext('successremoval'))
999            . ' ' . html::a(array('onclick' => sprintf("%s.http_request('event', 'action=undo', %s.display_message('', 'loading'))",
1000              rcmail_output::JS_OBJECT_NAME, rcmail_output::JS_OBJECT_NAME)), $this->gettext('undo'));
1001          $this->rc->output->show_message($msg, 'confirmation', null, true, $undo_time);
1002          $got_msg = true;
1003        }
1004        else if ($success) {
1005          $this->rc->output->show_message('calendar.successremoval', 'confirmation');
1006          $got_msg = true;
1007        }
1008
1009        // send cancellation for the main event
1010        if ($event['_savemode'] == 'all') {
1011          unset($old['_instance'], $old['recurrence_date'], $old['recurrence_id']);
1012        }
1013        // send an update for the main event's recurrence rule instead of a cancellation message
1014        else if ($event['_savemode'] == 'future' && $success !== false && $success !== true) {
1015          $event['_savemode'] = 'all';  // force event_save_success() to load master event
1016          $action = 'edit';
1017          $success = true;
1018        }
1019
1020        // send iTIP reply that participant has declined the event
1021        if ($success && $event['_decline']) {
1022          $emails = $this->get_user_emails();
1023          foreach ($old['attendees'] as $i => $attendee) {
1024            if ($attendee['role'] == 'ORGANIZER')
1025              $organizer = $attendee;
1026            else if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) {
1027              $old['attendees'][$i]['status'] = 'DECLINED';
1028              $reply_sender = $attendee['email'];
1029            }
1030          }
1031
1032          if ($event['_savemode'] == 'future' && $event['id'] != $old['id']) {
1033            $old['thisandfuture'] = true;
1034          }
1035
1036          $itip = $this->load_itip();
1037          $itip->set_sender_email($reply_sender);
1038          if ($organizer && $itip->send_itip_message($old, 'REPLY', $organizer, 'itipsubjectdeclined', 'itipmailbodydeclined'))
1039            $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation');
1040          else
1041            $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
1042        }
1043        else if ($success) {
1044          $this->event_save_success($event, $old, $action, $success);
1045        }
1046        break;
1047
1048      case "undo":
1049        // Restore deleted event
1050        if ($event = $_SESSION['calendar_event_undo']['data'])
1051          $success = $this->driver->restore_event($event);
1052
1053        if ($success) {
1054          $this->rc->session->remove('calendar_event_undo');
1055          $this->rc->output->show_message('calendar.successrestore', 'confirmation');
1056          $got_msg = true;
1057          $reload  = 2;
1058        }
1059
1060        break;
1061
1062      case "rsvp":
1063        $itip_sending  = $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']);
1064        $status        = rcube_utils::get_input_value('status', rcube_utils::INPUT_POST);
1065        $attendees     = rcube_utils::get_input_value('attendees', rcube_utils::INPUT_POST);
1066        $reply_comment = $event['comment'];
1067
1068        $this->write_preprocess($event, 'edit');
1069        $ev = $this->driver->get_event($event);
1070        $ev['attendees'] = $event['attendees'];
1071        $ev['free_busy'] = $event['free_busy'];
1072        $ev['_savemode'] = $event['_savemode'];
1073        $ev['comment']   = $reply_comment;
1074
1075        // send invitation to delegatee + add it as attendee
1076        if ($status == 'delegated' && $event['to']) {
1077          $itip = $this->load_itip();
1078          if ($itip->delegate_to($ev, $event['to'], (bool)$event['rsvp'], $attendees)) {
1079            $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation');
1080            $noreply = false;
1081          }
1082        }
1083
1084        $event = $ev;
1085
1086        // compose a list of attendees affected by this change
1087        $updated_attendees = array_filter(array_map(function($j) use ($event) {
1088          return $event['attendees'][$j];
1089        }, $attendees));
1090
1091        if ($success = $this->driver->edit_rsvp($event, $status, $updated_attendees)) {
1092          $noreply = rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC);
1093          $noreply = intval($noreply) || $status == 'needs-action' || $itip_sending === 0;
1094          $reload  = $event['calendar'] != $ev['calendar'] || $event['recurrence'] ? 2 : 1;
1095          $organizer = null;
1096          $emails = $this->get_user_emails();
1097
1098          foreach ($event['attendees'] as $i => $attendee) {
1099            if ($attendee['role'] == 'ORGANIZER') {
1100              $organizer = $attendee;
1101            }
1102            else if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) {
1103              $reply_sender = $attendee['email'];
1104            }
1105          }
1106
1107          if (!$noreply) {
1108            $itip = $this->load_itip();
1109            $itip->set_sender_email($reply_sender);
1110            $event['thisandfuture'] = $event['_savemode'] == 'future';
1111            if ($organizer && $itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status))
1112              $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation');
1113            else
1114              $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
1115          }
1116
1117          // refresh all calendars
1118          if ($event['calendar'] != $ev['calendar']) {
1119            $this->rc->output->command('plugin.refresh_calendar', array('source' => null, 'refetch' => true));
1120            $reload = 0;
1121          }
1122        }
1123        break;
1124
1125      case "dismiss":
1126        $event['ids'] = explode(',', $event['id']);
1127        $plugin = $this->rc->plugins->exec_hook('dismiss_alarms', $event);
1128        $success = $plugin['success'];
1129        foreach ($event['ids'] as $id) {
1130            if (strpos($id, 'cal:') === 0)
1131                $success |= $this->driver->dismiss_alarm(substr($id, 4), $event['snooze']);
1132        }
1133        break;
1134
1135      case "changelog":
1136        $data = $this->driver->get_event_changelog($event);
1137        if (is_array($data) && !empty($data)) {
1138          $lib = $this->lib;
1139          $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format');
1140          array_walk($data, function(&$change) use ($lib, $dtformat) {
1141            if ($change['date']) {
1142              $dt = $lib->adjust_timezone($change['date']);
1143              if ($dt instanceof DateTime)
1144                $change['date'] = $this->rc->format_date($dt, $dtformat, false);
1145            }
1146          });
1147          $this->rc->output->command('plugin.render_event_changelog', $data);
1148        }
1149        else {
1150          $this->rc->output->command('plugin.render_event_changelog', false);
1151        }
1152        $got_msg = true;
1153        $reload = false;
1154        break;
1155
1156      case "diff":
1157        $data = $this->driver->get_event_diff($event, $event['rev1'], $event['rev2']);
1158        if (is_array($data)) {
1159          // convert some properties, similar to self::_client_event()
1160          $lib = $this->lib;
1161          array_walk($data['changes'], function(&$change, $i) use ($event, $lib) {
1162            // convert date cols
1163            foreach (array('start','end','created','changed') as $col) {
1164              if ($change['property'] == $col) {
1165                $change['old'] = $lib->adjust_timezone($change['old'], strlen($change['old']) == 10)->format('c');
1166                $change['new'] = $lib->adjust_timezone($change['new'], strlen($change['new']) == 10)->format('c');
1167              }
1168            }
1169            // create textual representation for alarms and recurrence
1170            if ($change['property'] == 'alarms') {
1171              if (is_array($change['old']))
1172                $change['old_'] = libcalendaring::alarm_text($change['old']);
1173              if (is_array($change['new']))
1174                $change['new_'] = libcalendaring::alarm_text(array_merge((array)$change['old'], $change['new']));
1175            }
1176            if ($change['property'] == 'recurrence') {
1177              if (is_array($change['old']))
1178                $change['old_'] = $lib->recurrence_text($change['old']);
1179              if (is_array($change['new']))
1180                $change['new_'] = $lib->recurrence_text(array_merge((array)$change['old'], $change['new']));
1181            }
1182            if ($change['property'] == 'attachments') {
1183              if (is_array($change['old']))
1184                $change['old']['classname'] = rcube_utils::file2class($change['old']['mimetype'], $change['old']['name']);
1185              if (is_array($change['new']))
1186                $change['new']['classname'] = rcube_utils::file2class($change['new']['mimetype'], $change['new']['name']);
1187            }
1188            // compute a nice diff of description texts
1189            if ($change['property'] == 'description') {
1190              $change['diff_'] = libkolab::html_diff($change['old'], $change['new']);
1191            }
1192          });
1193          $this->rc->output->command('plugin.event_show_diff', $data);
1194        }
1195        else {
1196          $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error');
1197        }
1198        $got_msg = true;
1199        $reload = false;
1200        break;
1201
1202      case "show":
1203        if ($event = $this->driver->get_event_revison($event, $event['rev'])) {
1204          $this->rc->output->command('plugin.event_show_revision', $this->_client_event($event));
1205        }
1206        else {
1207          $this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error');
1208        }
1209        $got_msg = true;
1210        $reload = false;
1211        break;
1212
1213      case "restore":
1214        if ($success = $this->driver->restore_event_revision($event, $event['rev'])) {
1215          $_event = $this->driver->get_event($event);
1216          $reload = $_event['recurrence'] ? 2 : 1;
1217          $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $event['rev']))), 'confirmation');
1218          $this->rc->output->command('plugin.close_history_dialog');
1219        }
1220        else {
1221          $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error');
1222          $reload = 0;
1223        }
1224        $got_msg = true;
1225        break;
1226    }
1227
1228    // show confirmation/error message
1229    if (!$got_msg) {
1230      if ($success)
1231        $this->rc->output->show_message('successfullysaved', 'confirmation');
1232      else
1233        $this->rc->output->show_message('calendar.errorsaving', 'error');
1234    }
1235
1236    // unlock client
1237    $this->rc->output->command('plugin.unlock_saving', $success);
1238
1239    // update event object on the client or trigger a complete refresh if too complicated
1240    if ($reload && empty($_REQUEST['_framed'])) {
1241      $args = array('source' => $event['calendar']);
1242      if ($reload > 1)
1243        $args['refetch'] = true;
1244      else if ($success && $action != 'remove')
1245        $args['update'] = $this->_client_event($this->driver->get_event($event), true);
1246      $this->rc->output->command('plugin.refresh_calendar', $args);
1247    }
1248  }
1249
1250  /**
1251   * Helper method sending iTip notifications after successful event updates
1252   */
1253  private function event_save_success(&$event, $old, $action, $success)
1254  {
1255    // $success is a new event ID
1256    if ($success !== true) {
1257      // send update notification on the main event
1258      if ($event['_savemode'] == 'future' && $event['_notify'] && $old['attendees'] && $old['recurrence_id']) {
1259        $master = $this->driver->get_event(array('id' => $old['recurrence_id'], 'calendar' => $old['calendar']), 0, true);
1260        unset($master['_instance'], $master['recurrence_date']);
1261
1262        $sent = $this->notify_attendees($master, null, $action, $event['_comment'], false);
1263        if ($sent < 0)
1264          $this->rc->output->show_message('calendar.errornotifying', 'error');
1265
1266        $event['attendees'] = $master['attendees'];  // this tricks us into the next if clause
1267      }
1268
1269      // delete old reference if saved as new
1270      if ($event['_savemode'] == 'future' || $event['_savemode'] == 'new') {
1271        $old = null;
1272      }
1273
1274      $event['id'] = $success;
1275      $event['_savemode'] = 'all';
1276    }
1277
1278    // send out notifications
1279    if ($event['_notify'] && ($event['attendees'] || $old['attendees'])) {
1280      $_savemode = $event['_savemode'];
1281
1282      // send notification for the main event when savemode is 'all'
1283      if ($action != 'remove' && $_savemode == 'all' && ($event['recurrence_id'] || $old['recurrence_id'] || ($old && $old['id'] != $event['id']))) {
1284        $event['id'] = $event['recurrence_id'] ?: ($old['recurrence_id'] ?: $old['id']);
1285        $event = $this->driver->get_event($event, 0, true);
1286        unset($event['_instance'], $event['recurrence_date']);
1287      }
1288      else {
1289        // make sure we have the complete record
1290        $event = $action == 'remove' ? $old : $this->driver->get_event($event, 0, true);
1291      }
1292
1293      $event['_savemode'] = $_savemode;
1294
1295      if ($old) {
1296        $old['thisandfuture'] = $_savemode == 'future';
1297      }
1298
1299      // only notify if data really changed (TODO: do diff check on client already)
1300      if (!$old || $action == 'remove' || self::event_diff($event, $old)) {
1301        $sent = $this->notify_attendees($event, $old, $action, $event['_comment']);
1302        if ($sent > 0)
1303          $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation');
1304        else if ($sent < 0)
1305          $this->rc->output->show_message('calendar.errornotifying', 'error');
1306      }
1307    }
1308  }
1309
1310  /**
1311   * Handler for load-requests from fullcalendar
1312   * This will return pure JSON formatted output
1313   */
1314  function load_events()
1315  {
1316    $start  = $this->input_timestamp('start', rcube_utils::INPUT_GET);
1317    $end    = $this->input_timestamp('end', rcube_utils::INPUT_GET);
1318    $query  = rcube_utils::get_input_value('q', rcube_utils::INPUT_GET);
1319    $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GET);
1320
1321    $events = $this->driver->load_events($start, $end, $query, $source);
1322    echo $this->encode($events, !empty($query));
1323    exit;
1324  }
1325
1326  /**
1327   * Handler for requests fetching event counts for calendars
1328   */
1329  public function count_events()
1330  {
1331    // don't update session on these requests (avoiding race conditions)
1332    $this->rc->session->nowrite = true;
1333
1334    $start = rcube_utils::get_input_value('start', rcube_utils::INPUT_GET);
1335    if (!$start) {
1336      $start = new DateTime('today 00:00:00', $this->timezone);
1337      $start = $start->format('U');
1338    }
1339
1340    $counts = $this->driver->count_events(
1341      rcube_utils::get_input_value('source', rcube_utils::INPUT_GET),
1342      $start,
1343      rcube_utils::get_input_value('end', rcube_utils::INPUT_GET)
1344    );
1345
1346    $this->rc->output->command('plugin.update_counts', array('counts' => $counts));
1347  }
1348
1349  /**
1350   * Load event data from an iTip message attachment
1351   */
1352  public function itip_events($msgref)
1353  {
1354    $path = explode('/', $msgref);
1355    $msg  = array_pop($path);
1356    $mbox = join('/', $path);
1357    list($uid, $mime_id) = explode('#', $msg);
1358    $events = array();
1359
1360    if ($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) {
1361      $partstat = 'NEEDS-ACTION';
1362/*
1363      $user_emails = $this->lib->get_user_emails();
1364      foreach ($event['attendees'] as $attendee) {
1365        if (in_array($attendee['email'], $user_emails)) {
1366          $partstat = $attendee['status'];
1367          break;
1368        }
1369      }
1370*/
1371      $event['id']        = $event['uid'];
1372      $event['temporary'] = true;
1373      $event['readonly']  = true;
1374      $event['calendar']  = '--invitation--itip';
1375      $event['className'] = 'fc-invitation-' . strtolower($partstat);
1376      $event['_mbox']     = $mbox;
1377      $event['_uid']      = $uid;
1378      $event['_part']     = $mime_id;
1379
1380      $events[] = $this->_client_event($event, true);
1381
1382      // add recurring instances
1383      if (!empty($event['recurrence'])) {
1384        // Some installations can't handle all occurrences (aborting the request w/o an error in log)
1385        $end = clone $event['start'];
1386        $end->add(new DateInterval($event['recurrence']['FREQ'] == 'DAILY' ? 'P1Y' : 'P10Y'));
1387
1388        foreach ($this->driver->get_recurring_events($event, $event['start'], $end) as $recurring) {
1389          $recurring['temporary'] = true;
1390          $recurring['readonly']  = true;
1391          $recurring['calendar']  = '--invitation--itip';
1392          $events[] = $this->_client_event($recurring, true);
1393        }
1394      }
1395    }
1396
1397    return $events;
1398  }
1399
1400  /**
1401   * Handler for keep-alive requests
1402   * This will check for updated data in active calendars and sync them to the client
1403   */
1404  public function refresh($attr)
1405  {
1406     // refresh the entire calendar every 10th time to also sync deleted events
1407    if (rand(0,10) == 10) {
1408        $this->rc->output->command('plugin.refresh_calendar', array('refetch' => true));
1409        return;
1410    }
1411
1412    $counts = array();
1413
1414    foreach ($this->driver->list_calendars(calendar_driver::FILTER_ACTIVE) as $cal) {
1415      $events = $this->driver->load_events(
1416        rcube_utils::get_input_value('start', rcube_utils::INPUT_GPC),
1417        rcube_utils::get_input_value('end', rcube_utils::INPUT_GPC),
1418        rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC),
1419        $cal['id'],
1420        1,
1421        $attr['last']
1422      );
1423
1424      foreach ($events as $event) {
1425        $this->rc->output->command('plugin.refresh_calendar',
1426          array('source' => $cal['id'], 'update' => $this->_client_event($event)));
1427      }
1428
1429      // refresh count for this calendar
1430      if ($cal['counts']) {
1431        $today = new DateTime('today 00:00:00', $this->timezone);
1432        $counts += $this->driver->count_events($cal['id'], $today->format('U'));
1433      }
1434    }
1435
1436    if (!empty($counts)) {
1437      $this->rc->output->command('plugin.update_counts', array('counts' => $counts));
1438    }
1439  }
1440
1441  /**
1442   * Handler for pending_alarms plugin hook triggered by the calendar module on keep-alive requests.
1443   * This will check for pending notifications and pass them to the client
1444   */
1445  public function pending_alarms($p)
1446  {
1447    $this->load_driver();
1448    $time = $p['time'] ?: time();
1449    if ($alarms = $this->driver->pending_alarms($time)) {
1450      foreach ($alarms as $alarm) {
1451        $alarm['id'] = 'cal:' . $alarm['id'];  // prefix ID with cal:
1452        $p['alarms'][] = $alarm;
1453      }
1454    }
1455
1456    // get alarms for birthdays calendar
1457    if ($this->rc->config->get('calendar_contact_birthdays') && $this->rc->config->get('calendar_birthdays_alarm_type') == 'DISPLAY') {
1458      $cache = $this->rc->get_cache('calendar.birthdayalarms', 'db');
1459
1460      foreach ($this->driver->load_birthday_events($time, $time + 86400 * 60) as $e) {
1461        $alarm = libcalendaring::get_next_alarm($e);
1462
1463        // overwrite alarm time with snooze value (or null if dismissed)
1464        if ($dismissed = $cache->get($e['id']))
1465          $alarm['time'] = $dismissed['notifyat'];
1466
1467        // add to list if alarm is set
1468        if ($alarm && $alarm['time'] && $alarm['time'] <= $time) {
1469          $e['id'] = 'cal:bday:' . $e['id'];
1470          $e['notifyat'] = $alarm['time'];
1471          $p['alarms'][] = $e;
1472        }
1473      }
1474    }
1475
1476    return $p;
1477  }
1478
1479  /**
1480   * Handler for alarm dismiss hook triggered by libcalendaring
1481   */
1482  public function dismiss_alarms($p)
1483  {
1484      $this->load_driver();
1485      foreach ((array)$p['ids'] as $id) {
1486          if (strpos($id, 'cal:bday:') === 0) {
1487              $p['success'] |= $this->driver->dismiss_birthday_alarm(substr($id, 9), $p['snooze']);
1488          }
1489          else if (strpos($id, 'cal:') === 0) {
1490              $p['success'] |= $this->driver->dismiss_alarm(substr($id, 4), $p['snooze']);
1491          }
1492      }
1493
1494      return $p;
1495  }
1496
1497  /**
1498   * Handler for check-recent requests which are accidentally sent to calendar
1499   */
1500  function check_recent()
1501  {
1502    // NOP
1503    $this->rc->output->send();
1504  }
1505
1506  /**
1507   * Hook triggered when a contact is saved
1508   */
1509  function contact_update($p)
1510  {
1511    // clear birthdays calendar cache
1512    if (!empty($p['record']['birthday'])) {
1513      $cache = $this->rc->get_cache('calendar.birthdays', 'db');
1514      $cache->remove();
1515    }
1516  }
1517
1518  /**
1519   *
1520   */
1521  function import_events()
1522  {
1523    // Upload progress update
1524    if (!empty($_GET['_progress'])) {
1525      $this->rc->upload_progress();
1526    }
1527
1528    @set_time_limit(0);
1529
1530    // process uploaded file if there is no error
1531    $err = $_FILES['_data']['error'];
1532
1533    if (!$err && $_FILES['_data']['tmp_name']) {
1534      $calendar   = rcube_utils::get_input_value('calendar', rcube_utils::INPUT_GPC);
1535      $rangestart = $_REQUEST['_range'] ? date_create("now -" . intval($_REQUEST['_range']) . " months") : 0;
1536
1537      // extract zip file
1538      if ($_FILES['_data']['type'] == 'application/zip') {
1539        $count = 0;
1540        if (class_exists('ZipArchive', false)) {
1541          $zip = new ZipArchive();
1542          if ($zip->open($_FILES['_data']['tmp_name'])) {
1543            $randname = uniqid('zip-' . session_id(), true);
1544            $tmpdir = slashify($this->rc->config->get('temp_dir', sys_get_temp_dir())) . $randname;
1545            mkdir($tmpdir, 0700);
1546
1547            // extract each ical file from the archive and import it
1548            for ($i = 0; $i < $zip->numFiles; $i++) {
1549              $filename = $zip->getNameIndex($i);
1550              if (preg_match('/\.ics$/i', $filename)) {
1551                $tmpfile = $tmpdir . '/' . basename($filename);
1552                if (copy('zip://' . $_FILES['_data']['tmp_name'] . '#'.$filename, $tmpfile)) {
1553                  $count += $this->import_from_file($tmpfile, $calendar, $rangestart, $errors);
1554                  unlink($tmpfile);
1555                }
1556              }
1557            }
1558
1559            rmdir($tmpdir);
1560            $zip->close();
1561          }
1562          else {
1563            $errors = 1;
1564            $msg = 'Failed to open zip file.';
1565          }
1566        }
1567        else {
1568          $errors = 1;
1569          $msg = 'Zip files are not supported for import.';
1570        }
1571      }
1572      else {
1573        // attempt to import teh uploaded file directly
1574        $count = $this->import_from_file($_FILES['_data']['tmp_name'], $calendar, $rangestart, $errors);
1575      }
1576
1577      if ($count) {
1578        $this->rc->output->command('display_message', $this->gettext(array('name' => 'importsuccess', 'vars' => array('nr' => $count))), 'confirmation');
1579        $this->rc->output->command('plugin.import_success', array('source' => $calendar, 'refetch' => true));
1580      }
1581      else if (!$errors) {
1582        $this->rc->output->command('display_message', $this->gettext('importnone'), 'notice');
1583        $this->rc->output->command('plugin.import_success', array('source' => $calendar));
1584      }
1585      else {
1586        $this->rc->output->command('plugin.import_error', array('message' => $this->gettext('importerror') . ($msg ? ': ' . $msg : '')));
1587      }
1588    }
1589    else {
1590      if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) {
1591        $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array(
1592            'size' => $this->rc->show_bytes(parse_bytes(ini_get('upload_max_filesize'))))));
1593      }
1594      else {
1595        $msg = $this->rc->gettext('fileuploaderror');
1596      }
1597
1598      $this->rc->output->command('plugin.import_error', array('message' => $msg));
1599    }
1600
1601    $this->rc->output->send('iframe');
1602  }
1603
1604  /**
1605   * Helper function to parse and import a single .ics file
1606   */
1607  private function import_from_file($filepath, $calendar, $rangestart, &$errors)
1608  {
1609    $user_email = $this->rc->user->get_username();
1610
1611    $ical = $this->get_ical();
1612    $errors = !$ical->fopen($filepath);
1613    $count = $i = 0;
1614    foreach ($ical as $event) {
1615      // keep the browser connection alive on long import jobs
1616      if (++$i > 100 && $i % 100 == 0) {
1617          echo "<!-- -->";
1618          ob_flush();
1619      }
1620
1621      // TODO: correctly handle recurring events which start before $rangestart
1622      if ($event['end'] < $rangestart && (!$event['recurrence'] || ($event['recurrence']['until'] && $event['recurrence']['until'] < $rangestart)))
1623        continue;
1624
1625      $event['_owner'] = $user_email;
1626      $event['calendar'] = $calendar;
1627      if ($this->driver->new_event($event)) {
1628        $count++;
1629      }
1630      else {
1631        $errors++;
1632      }
1633    }
1634
1635    return $count;
1636  }
1637
1638
1639  /**
1640   * Construct the ics file for exporting events to iCalendar format;
1641   */
1642  function export_events($terminate = true)
1643  {
1644    $start = rcube_utils::get_input_value('start', rcube_utils::INPUT_GET);
1645    $end   = rcube_utils::get_input_value('end', rcube_utils::INPUT_GET);
1646
1647    if (!isset($start))
1648      $start = 'today -1 year';
1649    if (!is_numeric($start))
1650      $start = strtotime($start . ' 00:00:00');
1651    if (!$end)
1652      $end = 'today +10 years';
1653    if (!is_numeric($end))
1654      $end = strtotime($end . ' 23:59:59');
1655
1656    $event_id    = rcube_utils::get_input_value('id', rcube_utils::INPUT_GET);
1657    $attachments = rcube_utils::get_input_value('attachments', rcube_utils::INPUT_GET);
1658    $calid = $filename = rcube_utils::get_input_value('source', rcube_utils::INPUT_GET);
1659
1660    $calendars = $this->driver->list_calendars();
1661    $events = array();
1662
1663    if ($calendars[$calid]) {
1664      $filename = $calendars[$calid]['name'] ? $calendars[$calid]['name'] : $calid;
1665      $filename = asciiwords(html_entity_decode($filename));  // to 7bit ascii
1666      if (!empty($event_id)) {
1667        if ($event = $this->driver->get_event(array('calendar' => $calid, 'id' => $event_id), 0, true)) {
1668          if ($event['recurrence_id']) {
1669            $event = $this->driver->get_event(array('calendar' => $calid, 'id' => $event['recurrence_id']), 0, true);
1670          }
1671          $events = array($event);
1672          $filename = asciiwords($event['title']);
1673          if (empty($filename))
1674            $filename = 'event';
1675        }
1676      }
1677      else {
1678         $events = $this->driver->load_events($start, $end, null, $calid, 0);
1679         if (empty($filename))
1680           $filename = $calid;
1681      }
1682    }
1683
1684    header("Content-Type: text/calendar");
1685    header("Content-Disposition: inline; filename=".$filename.'.ics');
1686
1687    $this->get_ical()->export($events, '', true, $attachments ? array($this->driver, 'get_attachment_body') : null);
1688
1689    if ($terminate)
1690      exit;
1691  }
1692
1693
1694  /**
1695   * Handler for iCal feed requests
1696   */
1697  function ical_feed_export()
1698  {
1699    $session_exists = !empty($_SESSION['user_id']);
1700
1701    // process HTTP auth info
1702    if (!empty($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) {
1703      $_POST['_user'] = $_SERVER['PHP_AUTH_USER']; // used for rcmail::autoselect_host()
1704      $auth = $this->rc->plugins->exec_hook('authenticate', array(
1705        'host' => $this->rc->autoselect_host(),
1706        'user' => trim($_SERVER['PHP_AUTH_USER']),
1707        'pass' => $_SERVER['PHP_AUTH_PW'],
1708        'cookiecheck' => true,
1709        'valid' => true,
1710      ));
1711      if ($auth['valid'] && !$auth['abort'])
1712        $this->rc->login($auth['user'], $auth['pass'], $auth['host']);
1713    }
1714
1715    // require HTTP auth
1716    if (empty($_SESSION['user_id'])) {
1717      header('WWW-Authenticate: Basic realm="Roundcube Calendar"');
1718      header('HTTP/1.0 401 Unauthorized');
1719      exit;
1720    }
1721
1722    // decode calendar feed hash
1723    $format = 'ics';
1724    $calhash = rcube_utils::get_input_value('_cal', rcube_utils::INPUT_GET);
1725    if (preg_match(($suff_regex = '/\.([a-z0-9]{3,5})$/i'), $calhash, $m)) {
1726      $format = strtolower($m[1]);
1727      $calhash = preg_replace($suff_regex, '', $calhash);
1728    }
1729
1730    if (!strpos($calhash, ':'))
1731      $calhash = base64_decode($calhash);
1732
1733    list($user, $_GET['source']) = explode(':', $calhash, 2);
1734
1735    // sanity check user
1736    if ($this->rc->user->get_username() == $user) {
1737      $this->setup();
1738      $this->load_driver();
1739      $this->export_events(false);
1740    }
1741    else {
1742      header('HTTP/1.0 404 Not Found');
1743    }
1744
1745    // don't save session data
1746    if (!$session_exists)
1747      session_destroy();
1748    exit;
1749  }
1750
1751  /**
1752   *
1753   */
1754  function load_settings()
1755  {
1756    $this->lib->load_settings();
1757    $this->defaults += $this->lib->defaults;
1758
1759    $settings = array();
1760
1761    // configuration
1762    $settings['default_view']     = (string) $this->rc->config->get('calendar_default_view', $this->defaults['calendar_default_view']);
1763    $settings['timeslots']        = (int) $this->rc->config->get('calendar_timeslots', $this->defaults['calendar_timeslots']);
1764    $settings['first_day']        = (int) $this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']);
1765    $settings['first_hour']       = (int) $this->rc->config->get('calendar_first_hour', $this->defaults['calendar_first_hour']);
1766    $settings['work_start']       = (int) $this->rc->config->get('calendar_work_start', $this->defaults['calendar_work_start']);
1767    $settings['work_end']         = (int) $this->rc->config->get('calendar_work_end', $this->defaults['calendar_work_end']);
1768    $settings['agenda_range']     = (int) $this->rc->config->get('calendar_agenda_range', $this->defaults['calendar_agenda_range']);
1769    $settings['event_coloring']   = (int) $this->rc->config->get('calendar_event_coloring', $this->defaults['calendar_event_coloring']);
1770    $settings['time_indicator']   = (int) $this->rc->config->get('calendar_time_indicator', $this->defaults['calendar_time_indicator']);
1771    $settings['invite_shared']    = (int) $this->rc->config->get('calendar_allow_invite_shared', $this->defaults['calendar_allow_invite_shared']);
1772    $settings['itip_notify']      = (int) $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']);
1773    $settings['show_weekno']      = (int) $this->rc->config->get('calendar_show_weekno', $this->defaults['calendar_show_weekno']);
1774    $settings['default_calendar'] = $this->rc->config->get('calendar_default_calendar');
1775    $settings['invitation_calendars'] = (bool) $this->rc->config->get('kolab_invitation_calendars', false);
1776
1777    // 'table' view has been replaced by 'list' view
1778    if ($settings['default_view'] == 'table') {
1779      $settings['default_view'] = 'list';
1780    }
1781
1782    // get user identity to create default attendee
1783    if ($this->ui->screen == 'calendar') {
1784      foreach ($this->rc->user->list_emails() as $rec) {
1785        if (!$identity)
1786          $identity = $rec;
1787        $identity['emails'][] = $rec['email'];
1788        $settings['identities'][$rec['identity_id']] = $rec['email'];
1789      }
1790      $identity['emails'][] = $this->rc->user->get_username();
1791      $settings['identity'] = array('name' => $identity['name'], 'email' => strtolower($identity['email']), 'emails' => ';' . strtolower(join(';', $identity['emails'])));
1792    }
1793
1794    // freebusy token authentication URL
1795    if (($url = $this->rc->config->get('calendar_freebusy_session_auth_url'))
1796      && ($uniqueid = $this->rc->config->get('kolab_uniqueid'))
1797    ) {
1798      if ($url === true) $url = '/freebusy';
1799      $url = rtrim(rcube_utils::resolve_url($url), '/ ');
1800      $url .= '/' . urlencode($this->rc->get_user_name());
1801      $url .= '/' . urlencode($uniqueid);
1802
1803      $settings['freebusy_url'] = $url;
1804    }
1805
1806    return $settings;
1807  }
1808
1809  /**
1810   * Encode events as JSON
1811   *
1812   * @param  array  Events as array
1813   * @param  boolean Add CSS class names according to calendar and categories
1814   * @return string JSON encoded events
1815   */
1816  function encode($events, $addcss = false)
1817  {
1818    $json = array();
1819    foreach ($events as $event) {
1820      $json[] = $this->_client_event($event, $addcss);
1821    }
1822    return rcube_output::json_serialize($json);
1823  }
1824
1825  /**
1826   * Convert an event object to be used on the client
1827   */
1828  private function _client_event($event, $addcss = false)
1829  {
1830    // compose a human readable strings for alarms_text and recurrence_text
1831    if ($event['valarms']) {
1832      $event['alarms_text'] = libcalendaring::alarms_text($event['valarms']);
1833      $event['valarms'] = libcalendaring::to_client_alarms($event['valarms']);
1834    }
1835    if ($event['recurrence']) {
1836      $event['recurrence_text'] = $this->lib->recurrence_text($event['recurrence']);
1837      $event['recurrence'] = $this->lib->to_client_recurrence($event['recurrence'], $event['allday']);
1838      unset($event['recurrence_date']);
1839    }
1840
1841    foreach ((array)$event['attachments'] as $k => $attachment) {
1842      $event['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']);
1843
1844      unset($event['attachments'][$k]['data'], $event['attachments'][$k]['content']);
1845
1846      if (!$attachment['id']) {
1847        $event['attachments'][$k]['id'] = $k;
1848      }
1849    }
1850
1851    // convert link URIs references into structs
1852    if (array_key_exists('links', $event)) {
1853        foreach ((array) $event['links'] as $i => $link) {
1854            if (strpos($link, 'imap://') === 0 && ($msgref = $this->driver->get_message_reference($link))) {
1855                $event['links'][$i] = $msgref;
1856            }
1857        }
1858    }
1859
1860    // check for organizer in attendees list
1861    $organizer = null;
1862    foreach ((array)$event['attendees'] as $i => $attendee) {
1863      if ($attendee['role'] == 'ORGANIZER') {
1864        $organizer = $attendee;
1865      }
1866      if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] == false) {
1867        $event['attendees'][$i]['noreply'] = true;
1868      }
1869      else {
1870        unset($event['attendees'][$i]['noreply']);
1871      }
1872    }
1873
1874    if ($organizer === null && !empty($event['organizer'])) {
1875      $organizer = $event['organizer'];
1876      $organizer['role'] = 'ORGANIZER';
1877      if (!is_array($event['attendees']))
1878        $event['attendees'] = array();
1879      array_unshift($event['attendees'], $organizer);
1880    }
1881
1882    // Convert HTML description into plain text
1883    if ($this->is_html($event)) {
1884      $h2t = new rcube_html2text($event['description'], false, true, 0);
1885      $event['description'] = trim($h2t->get_text());
1886    }
1887
1888    // mapping url => vurl, allday => allDay because of the fullcalendar client script
1889    $event['vurl'] = $event['url'];
1890    $event['allDay'] = !empty($event['allday']);
1891    unset($event['url']);
1892    unset($event['allday']);
1893
1894    $event['className'] = $event['className'] ? explode(' ', $event['className']) : array();
1895
1896    if ($event['allDay']) {
1897        $event['end'] = $event['end']->add(new DateInterval('P1D'));
1898    }
1899
1900    if ($_GET['mode'] == 'print') {
1901        $event['editable'] = false;
1902    }
1903
1904    return array(
1905      '_id'   => $event['calendar'] . ':' . $event['id'],  // unique identifier for fullcalendar
1906      'start' => $this->lib->adjust_timezone($event['start'], $event['allDay'])->format('c'),
1907      'end'   => $this->lib->adjust_timezone($event['end'], $event['allDay'])->format('c'),
1908      // 'changed' might be empty for event recurrences (Bug #2185)
1909      'changed' => $event['changed'] ? $this->lib->adjust_timezone($event['changed'])->format('c') : null,
1910      'created' => $event['created'] ? $this->lib->adjust_timezone($event['created'])->format('c') : null,
1911      'title'       => strval($event['title']),
1912      'description' => strval($event['description']),
1913      'location'    => strval($event['location']),
1914    ) + $event;
1915  }
1916
1917
1918  /**
1919   * Generate a unique identifier for an event
1920   */
1921  public function generate_uid()
1922  {
1923    return strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($this->rc->user->get_username()), 0, 16));
1924  }
1925
1926
1927  /**
1928   * TEMPORARY: generate random event data for testing
1929   * Create events by opening http://<roundcubeurl>/?_task=calendar&_action=randomdata&_num=500&_date=2014-08-01&_dev=120
1930   */
1931  public function generate_randomdata()
1932  {
1933    @set_time_limit(0);
1934
1935    $num   = $_REQUEST['_num'] ? intval($_REQUEST['_num']) : 100;
1936    $date  = $_REQUEST['_date'] ?: 'now';
1937    $dev   = $_REQUEST['_dev'] ?: 30;
1938    $cats  = array_keys($this->driver->list_categories());
1939    $cals  = $this->driver->list_calendars(calendar_driver::FILTER_ACTIVE);
1940    $count = 0;
1941
1942    while ($count++ < $num) {
1943      $spread = intval($dev) * 86400; // days
1944      $refdate = strtotime($date);
1945      $start = round(($refdate + rand(-$spread, $spread)) / 600) * 600;
1946      $duration = round(rand(30, 360) / 30) * 30 * 60;
1947      $allday = rand(0,20) > 18;
1948      $alarm = rand(-30,12) * 5;
1949      $fb = rand(0,2);
1950
1951      if (date('G', $start) > 23)
1952        $start -= 3600;
1953
1954      if ($allday) {
1955        $start = strtotime(date('Y-m-d 00:00:00', $start));
1956        $duration = 86399;
1957      }
1958
1959      $title = '';
1960      $len = rand(2, 12);
1961      $words = explode(" ", "The Hough transform is named after Paul Hough who patented the method in 1962. It is a technique which can be used to isolate features of a particular shape within an image. Because it requires that the desired features be specified in some parametric form, the classical Hough transform is most commonly used for the de- tection of regular curves such as lines, circles, ellipses, etc. A generalized Hough transform can be employed in applications where a simple analytic description of a feature(s) is not possible. Due to the computational complexity of the generalized Hough algorithm, we restrict the main focus of this discussion to the classical Hough transform. Despite its domain restrictions, the classical Hough transform (hereafter referred to without the classical prefix ) retains many applications, as most manufac- tured parts (and many anatomical parts investigated in medical imagery) contain feature boundaries which can be described by regular curves. The main advantage of the Hough transform technique is that it is tolerant of gaps in feature boundary descriptions and is relatively unaffected by image noise.");
1962//      $chars = "!# abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890";
1963      for ($i = 0; $i < $len; $i++)
1964        $title .= $words[rand(0,count($words)-1)] . " ";
1965
1966      $this->driver->new_event(array(
1967        'uid' => $this->generate_uid(),
1968        'start' => new DateTime('@'.$start),
1969        'end' => new DateTime('@'.($start + $duration)),
1970        'allday' => $allday,
1971        'title' => rtrim($title),
1972        'free_busy' => $fb == 2 ? 'outofoffice' : ($fb ? 'busy' : 'free'),
1973        'categories' => $cats[array_rand($cats)],
1974        'calendar' => array_rand($cals),
1975        'alarms' => $alarm > 0 ? "-{$alarm}M:DISPLAY" : '',
1976        'priority' => rand(0,9),
1977      ));
1978    }
1979
1980    $this->rc->output->redirect('');
1981  }
1982
1983  /**
1984   * Handler for attachments upload
1985   */
1986  public function attachment_upload()
1987  {
1988    $handler = new kolab_attachments_handler();
1989    $handler->attachment_upload(self::SESSION_KEY, 'cal-');
1990  }
1991
1992  /**
1993   * Handler for attachments download/displaying
1994   */
1995  public function attachment_get()
1996  {
1997    $handler = new kolab_attachments_handler();
1998
1999    // show loading page
2000    if (!empty($_GET['_preload'])) {
2001        return $handler->attachment_loading_page();
2002    }
2003
2004    $event_id = rcube_utils::get_input_value('_event', rcube_utils::INPUT_GPC);
2005    $calendar = rcube_utils::get_input_value('_cal', rcube_utils::INPUT_GPC);
2006    $id       = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC);
2007    $rev      = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC);
2008
2009    $event = array('id' => $event_id, 'calendar' => $calendar, 'rev' => $rev);
2010
2011    if ($calendar == '--invitation--itip') {
2012        $uid  = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GPC);
2013        $part = rcube_utils::get_input_value('_part', rcube_utils::INPUT_GPC);
2014        $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GPC);
2015
2016        $event      = $this->lib->mail_get_itip_object($mbox, $uid, $part, 'event');
2017        $attachment = $event['attachments'][$id];
2018        $attachment['body'] = &$attachment['data'];
2019    }
2020    else {
2021        $attachment = $this->driver->get_attachment($id, $event);
2022    }
2023
2024    // show part page
2025    if (!empty($_GET['_frame'])) {
2026        $handler->attachment_page($attachment);
2027    }
2028    // deliver attachment content
2029    else if ($attachment) {
2030        if ($calendar != '--invitation--itip') {
2031            $attachment['body'] = $this->driver->get_attachment_body($id, $event);
2032        }
2033
2034        $handler->attachment_get($attachment);
2035    }
2036
2037    // if we arrive here, the requested part was not found
2038    header('HTTP/1.1 404 Not Found');
2039    exit;
2040  }
2041
2042  /**
2043   * Determine whether the given event description is HTML formatted
2044   */
2045  private function is_html($event)
2046  {
2047      // check for opening and closing <html> or <body> tags
2048      return (preg_match('/<(html|body)(\s+[a-z]|>)/', $event['description'], $m) && strpos($event['description'], '</'.$m[1].'>') > 0);
2049  }
2050
2051  /**
2052   * Prepares new/edited event properties before save
2053   */
2054  private function write_preprocess(&$event, $action)
2055  {
2056    // Remove double timezone specification (T2313)
2057    $event['start'] = preg_replace('/\s*\(.*\)/', '', $event['start']);
2058    $event['end']   = preg_replace('/\s*\(.*\)/', '', $event['end']);
2059
2060    // convert dates into DateTime objects in user's current timezone
2061    $event['start']  = new DateTime($event['start'], $this->timezone);
2062    $event['end']    = new DateTime($event['end'], $this->timezone);
2063    $event['allday'] = !empty($event['allDay']);
2064    unset($event['allDay']);
2065
2066    // start/end is all we need for 'move' action (#1480)
2067    if ($action == 'move') {
2068      return true;
2069    }
2070
2071    // convert the submitted recurrence settings
2072    if (is_array($event['recurrence'])) {
2073      $event['recurrence'] = $this->lib->from_client_recurrence($event['recurrence'], $event['start']);
2074
2075      // align start date with the first occurrence
2076      if (!empty($event['recurrence']) && !empty($event['syncstart'])
2077        && (empty($event['_savemode']) || $event['_savemode'] == 'all')
2078      ) {
2079        $next = $this->find_first_occurrence($event);
2080
2081        if (!$next) {
2082          $this->rc->output->show_message('calendar.recurrenceerror', 'error');
2083          return false;
2084        }
2085        else if ($event['start'] != $next) {
2086          $diff = $event['start']->diff($event['end'], true);
2087
2088          $event['start'] = $next;
2089          $event['end']   = clone $next;
2090          $event['end']->add($diff);
2091        }
2092      }
2093    }
2094
2095    // convert the submitted alarm values
2096    if ($event['valarms']) {
2097      $event['valarms'] = libcalendaring::from_client_alarms($event['valarms']);
2098    }
2099
2100    $attachments = array();
2101    $eventid     = 'cal-'.$event['id'];
2102
2103    if (is_array($_SESSION[self::SESSION_KEY]) && $_SESSION[self::SESSION_KEY]['id'] == $eventid) {
2104      if (!empty($_SESSION[self::SESSION_KEY]['attachments'])) {
2105        foreach ($_SESSION[self::SESSION_KEY]['attachments'] as $id => $attachment) {
2106          if (is_array($event['attachments']) && in_array($id, $event['attachments'])) {
2107            $attachments[$id] = $this->rc->plugins->exec_hook('attachment_get', $attachment);
2108          }
2109        }
2110      }
2111    }
2112
2113    $event['attachments'] = $attachments;
2114
2115    // convert link references into simple URIs
2116    if (array_key_exists('links', $event)) {
2117      $event['links'] = array_map(function($link) {
2118          return is_array($link) ? $link['uri'] : strval($link);
2119        }, (array)$event['links']);
2120    }
2121
2122    // check for organizer in attendees
2123    if ($action == 'new' || $action == 'edit') {
2124      if (!$event['attendees'])
2125        $event['attendees'] = array();
2126
2127      $emails = $this->get_user_emails();
2128      $organizer = $owner = false;
2129      foreach ((array)$event['attendees'] as $i => $attendee) {
2130        if ($attendee['role'] == 'ORGANIZER')
2131          $organizer = $i;
2132        if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails))
2133          $owner = $i;
2134        if (!isset($attendee['rsvp']))
2135          $event['attendees'][$i]['rsvp'] = true;
2136        else if (is_string($attendee['rsvp']))
2137          $event['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1';
2138      }
2139
2140      if (!empty($event['_identity'])) {
2141        $identity = $this->rc->user->get_identity($event['_identity']);
2142      }
2143
2144      // set new organizer identity
2145      if ($organizer !== false && $identity) {
2146        $event['attendees'][$organizer]['name'] = $identity['name'];
2147        $event['attendees'][$organizer]['email'] = $identity['email'];
2148      }
2149      // set owner as organizer if yet missing
2150      else if ($organizer === false && $owner !== false) {
2151        $event['attendees'][$owner]['role'] = 'ORGANIZER';
2152        unset($event['attendees'][$owner]['rsvp']);
2153      }
2154      // fallback to the selected identity
2155      else if ($organizer === false && $identity) {
2156        $event['attendees'][] = array(
2157          'role'  => 'ORGANIZER',
2158          'name'  => $identity['name'],
2159          'email' => $identity['email'],
2160        );
2161      }
2162    }
2163
2164    // mapping url => vurl because of the fullcalendar client script
2165    if (array_key_exists('vurl', $event)) {
2166      $event['url'] = $event['vurl'];
2167      unset($event['vurl']);
2168    }
2169
2170    return true;
2171  }
2172
2173  /**
2174   * Releases some resources after successful event save
2175   */
2176  private function cleanup_event(&$event)
2177  {
2178    // remove temp. attachment files
2179    if (!empty($_SESSION[self::SESSION_KEY]) && ($eventid = $_SESSION[self::SESSION_KEY]['id'])) {
2180      $this->rc->plugins->exec_hook('attachments_cleanup', array('group' => $eventid));
2181      $this->rc->session->remove(self::SESSION_KEY);
2182    }
2183  }
2184
2185  /**
2186   * Send out an invitation/notification to all event attendees
2187   */
2188  private function notify_attendees($event, $old, $action = 'edit', $comment = null, $rsvp = null)
2189  {
2190    if ($action == 'remove' || ($event['status'] == 'CANCELLED' && $old['status'] != $event['status'])) {
2191      $event['cancelled'] = true;
2192      $is_cancelled = true;
2193    }
2194
2195    if ($rsvp === null)
2196      $rsvp = !$old || $event['sequence'] > $old['sequence'];
2197
2198    $itip = $this->load_itip();
2199    $emails = $this->get_user_emails();
2200    $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']);
2201
2202    // add comment to the iTip attachment
2203    $event['comment'] = $comment;
2204
2205    // set a valid recurrence-id if this is a recurrence instance
2206    libcalendaring::identify_recurrence_instance($event);
2207
2208    // compose multipart message using PEAR:Mail_Mime
2209    $method = $action == 'remove' ? 'CANCEL' : 'REQUEST';
2210    $message = $itip->compose_itip_message($event, $method, $rsvp);
2211
2212    // list existing attendees from $old event
2213    $old_attendees = array();
2214    foreach ((array)$old['attendees'] as $attendee) {
2215      $old_attendees[] = $attendee['email'];
2216    }
2217
2218    // send to every attendee
2219    $sent = 0; $current = array();
2220    foreach ((array)$event['attendees'] as $attendee) {
2221      $current[] = strtolower($attendee['email']);
2222
2223      // skip myself for obvious reasons
2224      if (!$attendee['email'] || in_array(strtolower($attendee['email']), $emails))
2225        continue;
2226
2227      // skip if notification is disabled for this attendee
2228      if ($attendee['noreply'] && $itip_notify & 2)
2229        continue;
2230
2231      // skip if this attendee has delegated and set RSVP=FALSE
2232      if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] === false)
2233        continue;
2234
2235      // which template to use for mail text
2236      $is_new = !in_array($attendee['email'], $old_attendees);
2237      $is_rsvp = $is_new || $event['sequence'] > $old['sequence'];
2238      $bodytext = $is_cancelled ? 'eventcancelmailbody' : ($is_new ? 'invitationmailbody' : 'eventupdatemailbody');
2239      $subject  = $is_cancelled ? 'eventcancelsubject'  : ($is_new ? 'invitationsubject' : ($event['title'] ? 'eventupdatesubject':'eventupdatesubjectempty'));
2240
2241      $event['comment'] = $comment;
2242
2243      // finally send the message
2244      if ($itip->send_itip_message($event, $method, $attendee, $subject, $bodytext, $message, $is_rsvp))
2245        $sent++;
2246      else
2247        $sent = -100;
2248    }
2249
2250    // TODO: on change of a recurring (main) event, also send updates to differing attendess of recurrence exceptions
2251
2252    // send CANCEL message to removed attendees
2253    foreach ((array)$old['attendees'] as $attendee) {
2254      if ($attendee['role'] == 'ORGANIZER' || !$attendee['email'] || in_array(strtolower($attendee['email']), $current))
2255        continue;
2256
2257      $vevent = $old;
2258      $vevent['cancelled'] = $is_cancelled;
2259      $vevent['attendees'] = array($attendee);
2260      $vevent['comment']   = $comment;
2261      if ($itip->send_itip_message($vevent, 'CANCEL', $attendee, 'eventcancelsubject', 'eventcancelmailbody'))
2262        $sent++;
2263      else
2264        $sent = -100;
2265    }
2266
2267    return $sent;
2268  }
2269
2270  /**
2271   * Echo simple free/busy status text for the given user and time range
2272   */
2273  public function freebusy_status()
2274  {
2275    $email = rcube_utils::get_input_value('email', rcube_utils::INPUT_GPC);
2276    $start = $this->input_timestamp('start', rcube_utils::INPUT_GPC);
2277    $end   = $this->input_timestamp('end', rcube_utils::INPUT_GPC);
2278
2279    if (!$start) $start = time();
2280    if (!$end) $end = $start + 3600;
2281
2282    $fbtypemap = array(calendar::FREEBUSY_UNKNOWN => 'UNKNOWN', calendar::FREEBUSY_FREE => 'FREE', calendar::FREEBUSY_BUSY => 'BUSY', calendar::FREEBUSY_TENTATIVE => 'TENTATIVE', calendar::FREEBUSY_OOF => 'OUT-OF-OFFICE');
2283    $status = 'UNKNOWN';
2284
2285    // if the backend has free-busy information
2286    $fblist = $this->driver->get_freebusy_list($email, $start, $end);
2287
2288    if (is_array($fblist)) {
2289      $status = 'FREE';
2290
2291      foreach ($fblist as $slot) {
2292        list($from, $to, $type) = $slot;
2293        if ($from < $end && $to > $start) {
2294          $status = isset($type) && $fbtypemap[$type] ? $fbtypemap[$type] : 'BUSY';
2295          break;
2296        }
2297      }
2298    }
2299
2300    // let this information be cached for 5min
2301    $this->rc->output->future_expire_header(300);
2302
2303    echo $status;
2304    exit;
2305  }
2306
2307  /**
2308   * Return a list of free/busy time slots within the given period
2309   * Echo data in JSON encoding
2310   */
2311  public function freebusy_times()
2312  {
2313    $email = rcube_utils::get_input_value('email', rcube_utils::INPUT_GPC);
2314    $start = $this->input_timestamp('start', rcube_utils::INPUT_GPC);
2315    $end   = $this->input_timestamp('end', rcube_utils::INPUT_GPC);
2316    $interval  = intval(rcube_utils::get_input_value('interval', rcube_utils::INPUT_GPC));
2317    $strformat = $interval > 60 ? 'Ymd' : 'YmdHis';
2318
2319    if (!$start) $start = time();
2320    if (!$end)   $end = $start + 86400 * 30;
2321    if (!$interval) $interval = 60;  // 1 hour
2322
2323    if (!$dte) {
2324      $dts = new DateTime('@'.$start);
2325      $dts->setTimezone($this->timezone);
2326    }
2327
2328    $fblist = $this->driver->get_freebusy_list($email, $start, $end);
2329    $slots  = '';
2330
2331    // prepare freebusy list before use (for better performance)
2332    if (is_array($fblist)) {
2333      foreach ($fblist as $idx => $slot) {
2334        list($from, $to, ) = $slot;
2335
2336        // check for possible all-day times
2337        if (gmdate('His', $from) == '000000' && gmdate('His', $to) == '235959') {
2338          // shift into the user's timezone for sane matching
2339          $fblist[$idx][0] -= $this->gmt_offset;
2340          $fblist[$idx][1] -= $this->gmt_offset;
2341        }
2342      }
2343    }
2344
2345    // build a list from $start till $end with blocks representing the fb-status
2346    for ($s = 0, $t = $start; $t <= $end; $s++) {
2347      $t_end = $t + $interval * 60;
2348      $dt = new DateTime('@'.$t);
2349      $dt->setTimezone($this->timezone);
2350
2351      // determine attendee's status
2352      if (is_array($fblist)) {
2353        $status = self::FREEBUSY_FREE;
2354
2355        foreach ($fblist as $slot) {
2356          list($from, $to, $type) = $slot;
2357
2358          if ($from < $t_end && $to > $t) {
2359            $status = isset($type) ? $type : self::FREEBUSY_BUSY;
2360            if ($status == self::FREEBUSY_BUSY)  // can't get any worse :-)
2361              break;
2362          }
2363        }
2364      }
2365      else {
2366        $status = self::FREEBUSY_UNKNOWN;
2367      }
2368
2369      // use most compact format, assume $status is one digit/character
2370      $slots .= $status;
2371      $t = $t_end;
2372    }
2373
2374    $dte = new DateTime('@'.$t_end);
2375    $dte->setTimezone($this->timezone);
2376
2377    // let this information be cached for 5min
2378    $this->rc->output->future_expire_header(300);
2379
2380    echo rcube_output::json_serialize(array(
2381      'email' => $email,
2382      'start' => $dts->format('c'),
2383      'end'   => $dte->format('c'),
2384      'interval' => $interval,
2385      'slots' => $slots,
2386    ));
2387    exit;
2388  }
2389
2390  /**
2391   * Handler for printing calendars
2392   */
2393  public function print_view()
2394  {
2395    $title = $this->gettext('print');
2396
2397    $view = rcube_utils::get_input_value('view', rcube_utils::INPUT_GPC);
2398    if (!in_array($view, array('agendaWeek', 'agendaDay', 'month', 'list')))
2399      $view = 'agendaDay';
2400
2401    $this->rc->output->set_env('view', $view);
2402
2403    if ($date = rcube_utils::get_input_value('date', rcube_utils::INPUT_GPC))
2404      $this->rc->output->set_env('date', $date);
2405
2406    if ($range = rcube_utils::get_input_value('range', rcube_utils::INPUT_GPC))
2407      $this->rc->output->set_env('listRange', intval($range));
2408
2409    if ($search = rcube_utils::get_input_value('search', rcube_utils::INPUT_GPC)) {
2410      $this->rc->output->set_env('search', $search);
2411      $title .= ' "' . $search . '"';
2412    }
2413
2414    // Add JS to the page
2415    $this->ui->addJS();
2416
2417    $this->register_handler('plugin.calendar_css', array($this->ui, 'calendar_css'));
2418    $this->register_handler('plugin.calendar_list', array($this->ui, 'calendar_list'));
2419
2420    $this->rc->output->set_pagetitle($title);
2421    $this->rc->output->send('calendar.print');
2422  }
2423
2424  /**
2425   * Compare two event objects and return differing properties
2426   *
2427   * @param array Event A
2428   * @param array Event B
2429   * @return array List of differing event properties
2430   */
2431  public static function event_diff($a, $b)
2432  {
2433    $diff   = array();
2434    $ignore = array('changed' => 1, 'attachments' => 1);
2435
2436    foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) {
2437      if (!$ignore[$key] && $key[0] != '_' && $a[$key] != $b[$key]) {
2438        $diff[] = $key;
2439      }
2440    }
2441
2442    // only compare number of attachments
2443    if (count((array) $a['attachments']) != count((array) $b['attachments'])) {
2444      $diff[] = 'attachments';
2445    }
2446
2447    return $diff;
2448  }
2449
2450  /**
2451   * Update attendee properties on the given event object
2452   *
2453   * @param array The event object to be altered
2454   * @param array List of hash arrays each represeting an updated/added attendee
2455   */
2456  public static function merge_attendee_data(&$event, $attendees, $removed = null)
2457  {
2458    if (!empty($attendees) && !is_array($attendees[0])) {
2459      $attendees = array($attendees);
2460    }
2461
2462    foreach ($attendees as $attendee) {
2463      $found = false;
2464
2465      foreach ($event['attendees'] as $i => $candidate) {
2466        if ($candidate['email'] == $attendee['email']) {
2467          $event['attendees'][$i] = $attendee;
2468          $found = true;
2469          break;
2470        }
2471      }
2472
2473      if (!$found) {
2474        $event['attendees'][] = $attendee;
2475      }
2476    }
2477
2478    // filter out removed attendees
2479    if (!empty($removed)) {
2480      $event['attendees'] = array_filter($event['attendees'], function($attendee) use ($removed) {
2481        return !in_array($attendee['email'], $removed);
2482      });
2483    }
2484  }
2485
2486
2487  /****  Resource management functions  ****/
2488
2489  /**
2490   * Getter for the configured implementation of the resource directory interface
2491   */
2492  private function resources_directory()
2493  {
2494    if (is_object($this->resources_dir)) {
2495      return $this->resources_dir;
2496    }
2497
2498    if ($driver_name = $this->rc->config->get('calendar_resources_driver')) {
2499      $driver_class = 'resources_driver_' . $driver_name;
2500
2501      require_once($this->home . '/drivers/resources_driver.php');
2502      require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php');
2503
2504      $this->resources_dir = new $driver_class($this);
2505    }
2506
2507    return $this->resources_dir;
2508  }
2509
2510  /**
2511   * Handler for resoruce autocompletion requests
2512   */
2513  public function resources_autocomplete()
2514  {
2515    $search = rcube_utils::get_input_value('_search', rcube_utils::INPUT_GPC, true);
2516    $sid    = rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC);
2517    $maxnum = (int)$this->rc->config->get('autocomplete_max', 15);
2518    $results = array();
2519
2520    if ($directory = $this->resources_directory()) {
2521      foreach ($directory->load_resources($search, $maxnum) as $rec) {
2522        $results[]  = array(
2523            'name'  => $rec['name'],
2524            'email' => $rec['email'],
2525            'type'  => $rec['_type'],
2526        );
2527      }
2528    }
2529
2530    $this->rc->output->command('ksearch_query_results', $results, $search, $sid);
2531    $this->rc->output->send();
2532  }
2533
2534  /**
2535   * Handler for load-requests for resource data
2536   */
2537  function resources_list()
2538  {
2539    $data = array();
2540
2541    if ($directory = $this->resources_directory()) {
2542      foreach ($directory->load_resources() as $rec) {
2543        $data[] = $rec;
2544      }
2545    }
2546
2547    $this->rc->output->command('plugin.resource_data', $data);
2548    $this->rc->output->send();
2549  }
2550
2551  /**
2552   * Handler for requests loading resource owner information
2553   */
2554  function resources_owner()
2555  {
2556    if ($directory = $this->resources_directory()) {
2557      $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC);
2558      $data = $directory->get_resource_owner($id);
2559    }
2560
2561    $this->rc->output->command('plugin.resource_owner', $data);
2562    $this->rc->output->send();
2563  }
2564
2565  /**
2566   * Deliver event data for a resource's calendar
2567   */
2568  function resources_calendar()
2569  {
2570    $events = array();
2571
2572    if ($directory = $this->resources_directory()) {
2573      $id    = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC);
2574      $start = $this->input_timestamp('start', rcube_utils::INPUT_GET);
2575      $end   = $this->input_timestamp('end', rcube_utils::INPUT_GET);
2576
2577      $events = $directory->get_resource_calendar($id, $start, $end);
2578    }
2579
2580    echo $this->encode($events);
2581    exit;
2582  }
2583
2584
2585  /****  Event invitation plugin hooks ****/
2586
2587  /**
2588   * Find an event in user calendars
2589   */
2590  protected function find_event($event, &$mode)
2591  {
2592    $this->load_driver();
2593
2594    // We search for writeable calendars in personal namespace by default
2595    $mode   = calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL;
2596    $result = $this->driver->get_event($event, $mode);
2597    // ... now check shared folders if not found
2598    if (!$result) {
2599      $result = $this->driver->get_event($event, calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_SHARED);
2600      if ($result) {
2601        $mode |= calendar_driver::FILTER_SHARED;
2602      }
2603    }
2604
2605    return $result;
2606  }
2607
2608  /**
2609   * Handler for calendar/itip-status requests
2610   */
2611  function event_itip_status()
2612  {
2613    $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true);
2614
2615    $this->load_driver();
2616
2617    // find local copy of the referenced event (in personal namespace)
2618    $existing  = $this->find_event($data, $mode);
2619    $is_shared = $mode & calendar_driver::FILTER_SHARED;
2620    $itip      = $this->load_itip();
2621    $response  = $itip->get_itip_status($data, $existing);
2622
2623    // get a list of writeable calendars to save new events to
2624    if ((!$existing || $is_shared)
2625      && !$data['nosave']
2626      && ($response['action'] == 'rsvp' || $response['action'] == 'import')
2627    ) {
2628      $calendars       = $this->driver->list_calendars($mode);
2629      $calendar_select = new html_select(array(
2630          'name'       => 'calendar',
2631          'id'         => 'itip-saveto',
2632          'is_escaped' => true,
2633          'class'      => 'form-control custom-select'
2634      ));
2635      $calendar_select->add('--', '');
2636      $numcals = 0;
2637      foreach ($calendars as $calendar) {
2638        if ($calendar['editable']) {
2639          $calendar_select->add($calendar['name'], $calendar['id']);
2640          $numcals++;
2641        }
2642      }
2643      if ($numcals < 1)
2644        $calendar_select = null;
2645    }
2646
2647    if ($calendar_select) {
2648      $default_calendar = $this->get_default_calendar($data['sensitivity'], $calendars);
2649      $response['select'] = html::span('folder-select', $this->gettext('saveincalendar') . '&nbsp;' .
2650        $calendar_select->show($is_shared ? $existing['calendar'] : $default_calendar['id']));
2651    }
2652    else if ($data['nosave']) {
2653      $response['select'] = html::tag('input', array('type' => 'hidden', 'name' => 'calendar', 'id' => 'itip-saveto', 'value' => ''));
2654    }
2655
2656    // render small agenda view for the respective day
2657    if ($data['method'] == 'REQUEST' && !empty($data['date']) && $response['action'] == 'rsvp') {
2658      $event_start = rcube_utils::anytodatetime($data['date']);
2659      $day_start   = new Datetime(gmdate('Y-m-d 00:00', $data['date']), $this->lib->timezone);
2660      $day_end     = new Datetime(gmdate('Y-m-d 23:59', $data['date']), $this->lib->timezone);
2661
2662      // get events on that day from the user's personal calendars
2663      $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL);
2664      $events = $this->driver->load_events($day_start->format('U'), $day_end->format('U'), null, array_keys($calendars));
2665      usort($events, function($a, $b) { return $a['start'] > $b['start'] ? 1 : -1; });
2666
2667      $before = $after = array();
2668      foreach ($events as $event) {
2669        // TODO: skip events with free_busy == 'free' ?
2670        if ($event['uid'] == $data['uid']
2671            || $event['end'] < $day_start || $event['start'] > $day_end
2672            || $event['status'] == 'CANCELLED'
2673            || (!empty($event['className']) && strpos($event['className'], 'declined') !== false)
2674        ) {
2675          continue;
2676        }
2677
2678        if ($event['start'] < $event_start)
2679          $before[] = $this->mail_agenda_event_row($event);
2680        else
2681          $after[] = $this->mail_agenda_event_row($event);
2682      }
2683
2684      $response['append'] = array(
2685        'selector' => '.calendar-agenda-preview',
2686        'replacements' => array(
2687          '%before%' => !empty($before) ? join("\n", array_slice($before,  -3)) : html::div('event-row no-event', $this->gettext('noearlierevents')),
2688          '%after%'  => !empty($after)  ? join("\n", array_slice($after, 0, 3)) : html::div('event-row no-event', $this->gettext('nolaterevents')),
2689        ),
2690      );
2691    }
2692
2693    $this->rc->output->command('plugin.update_itip_object_status', $response);
2694  }
2695
2696  /**
2697   * Handler for calendar/itip-remove requests
2698   */
2699  function event_itip_remove()
2700  {
2701    $success  = false;
2702    $uid      = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST);
2703    $instance = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST);
2704    $savemode = rcube_utils::get_input_value('_savemode', rcube_utils::INPUT_POST);
2705    $listmode = calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL;
2706
2707    // search for event if only UID is given
2708    if ($event = $this->driver->get_event(array('uid' => $uid, '_instance' => $instance), $listmode)) {
2709      $event['_savemode'] = $savemode;
2710      $success = $this->driver->remove_event($event, true);
2711    }
2712
2713    if ($success) {
2714      $this->rc->output->show_message('calendar.successremoval', 'confirmation');
2715    }
2716    else {
2717      $this->rc->output->show_message('calendar.errorsaving', 'error');
2718    }
2719  }
2720
2721  /**
2722   * Handler for URLs that allow an invitee to respond on his invitation mail
2723   */
2724  public function itip_attend_response($p)
2725  {
2726    $this->setup();
2727
2728    if ($p['action'] == 'attend') {
2729      $this->ui->init();
2730
2731      $this->rc->output->set_env('task', 'calendar');  // override some env vars
2732      $this->rc->output->set_env('refresh_interval', 0);
2733      $this->rc->output->set_pagetitle($this->gettext('calendar'));
2734
2735      $itip  = $this->load_itip();
2736      $token = rcube_utils::get_input_value('_t', rcube_utils::INPUT_GPC);
2737
2738      // read event info stored under the given token
2739      if ($invitation = $itip->get_invitation($token)) {
2740        $this->token = $token;
2741        $this->event = $invitation['event'];
2742
2743        // show message about cancellation
2744        if ($invitation['cancelled']) {
2745          $this->invitestatus = html::div('rsvp-status declined', $itip->gettext('eventcancelled'));
2746        }
2747        // save submitted RSVP status
2748        else if (!empty($_POST['rsvp'])) {
2749          $status = null;
2750          foreach (array('accepted','tentative','declined') as $method) {
2751            if ($_POST['rsvp'] == $itip->gettext('itip' . $method)) {
2752              $status = $method;
2753              break;
2754            }
2755          }
2756
2757          // send itip reply to organizer
2758          $invitation['event']['comment'] = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST);
2759          if ($status && $itip->update_invitation($invitation, $invitation['attendee'], strtoupper($status))) {
2760            $this->invitestatus = html::div('rsvp-status ' . strtolower($status), $itip->gettext('youhave'.strtolower($status)));
2761          }
2762          else
2763            $this->rc->output->command('display_message', $this->gettext('errorsaving'), 'error', -1);
2764
2765          // if user is logged in...
2766          // FIXME: we should really consider removing this functionality
2767          //        it's confusing that it creates/updates an event only for logged-in user
2768          //        what if the logged-in user is not the same as the attendee?
2769          if ($this->rc->user->ID) {
2770            $this->load_driver();
2771
2772            $invitation = $itip->get_invitation($token);
2773            $existing   = $this->driver->get_event($this->event);
2774
2775            // save the event to his/her default calendar if not yet present
2776            if (!$existing && ($calendar = $this->get_default_calendar($invitation['event']['sensitivity']))) {
2777              $invitation['event']['calendar'] = $calendar['id'];
2778              if ($this->driver->new_event($invitation['event']))
2779                $this->rc->output->command('display_message', $this->gettext(array('name' => 'importedsuccessfully', 'vars' => array('calendar' => $calendar['name']))), 'confirmation');
2780              else
2781                $this->rc->output->command('display_message', $this->gettext('errorimportingevent'), 'error');
2782            }
2783            else if ($existing
2784              && ($this->event['sequence'] >= $existing['sequence'] || $this->event['changed'] >= $existing['changed'])
2785              && ($calendar = $this->driver->get_calendar($existing['calendar']))
2786            ) {
2787              $this->event       = $invitation['event'];
2788              $this->event['id'] = $existing['id'];
2789
2790              unset($this->event['comment']);
2791
2792              // merge attendees status
2793              // e.g. preserve my participant status for regular updates
2794              $this->lib->merge_attendees($this->event, $existing, $status);
2795
2796              // update attachments list
2797              $event['deleted_attachments'] = true;
2798
2799              // show me as free when declined (#1670)
2800              if ($status == 'declined')
2801                $this->event['free_busy'] = 'free';
2802
2803              if ($this->driver->edit_event($this->event))
2804                $this->rc->output->command('display_message', $this->gettext(array('name' => 'updatedsuccessfully', 'vars' => array('calendar' => $calendar->get_name()))), 'confirmation');
2805              else
2806                $this->rc->output->command('display_message', $this->gettext('errorimportingevent'), 'error');
2807            }
2808          }
2809        }
2810
2811        $this->register_handler('plugin.event_inviteform', array($this, 'itip_event_inviteform'));
2812        $this->register_handler('plugin.event_invitebox', array($this->ui, 'event_invitebox'));
2813
2814        if (!$this->invitestatus) {
2815          $this->itip->set_rsvp_actions(array('accepted','tentative','declined'));
2816          $this->register_handler('plugin.event_rsvp_buttons', array($this->ui, 'event_rsvp_buttons'));
2817        }
2818
2819        $this->rc->output->set_pagetitle($itip->gettext('itipinvitation') . ' ' . $this->event['title']);
2820      }
2821      else
2822        $this->rc->output->command('display_message', $this->gettext('itipinvalidrequest'), 'error', -1);
2823
2824      $this->rc->output->send('calendar.itipattend');
2825    }
2826  }
2827
2828  /**
2829   *
2830   */
2831  public function itip_event_inviteform($attrib)
2832  {
2833    $hidden = new html_hiddenfield(array('name' => "_t", 'value' => $this->token));
2834    return html::tag('form', array('action' => $this->rc->url(array('task' => 'calendar', 'action' => 'attend')), 'method' => 'post', 'noclose' => true) + $attrib) . $hidden->show();
2835  }
2836
2837  /**
2838   *
2839   */
2840  private function mail_agenda_event_row($event, $class = '')
2841  {
2842    $time = $event['allday'] ? $this->gettext('all-day') :
2843      $this->rc->format_date($event['start'], $this->rc->config->get('time_format')) . ' - ' .
2844        $this->rc->format_date($event['end'], $this->rc->config->get('time_format'));
2845
2846    return html::div(rtrim('event-row ' . ($class ?: $event['className'])),
2847      html::span('event-date', $time) .
2848      html::span('event-title', rcube::Q($event['title']))
2849    );
2850  }
2851
2852  /**
2853   *
2854   */
2855  public function mail_messages_list($p)
2856  {
2857    if (in_array('attachment', (array)$p['cols']) && !empty($p['messages'])) {
2858      foreach ($p['messages'] as $header) {
2859        $part = new StdClass;
2860        $part->mimetype = $header->ctype;
2861        if (libcalendaring::part_is_vcalendar($part)) {
2862          $header->list_flags['attachmentClass'] = 'ical';
2863        }
2864        else if (in_array($header->ctype, array('multipart/alternative', 'multipart/mixed'))) {
2865          // TODO: fetch bodystructure and search for ical parts. Maybe too expensive?
2866          if (!empty($header->structure) && is_array($header->structure->parts)) {
2867            foreach ($header->structure->parts as $part) {
2868              if (libcalendaring::part_is_vcalendar($part) && !empty($part->ctype_parameters['method'])) {
2869                $header->list_flags['attachmentClass'] = 'ical';
2870                break;
2871              }
2872            }
2873          }
2874        }
2875      }
2876    }
2877  }
2878
2879  /**
2880   * Add UI element to copy event invitations or updates to the calendar
2881   */
2882  public function mail_messagebody_html($p)
2883  {
2884    // load iCalendar functions (if necessary)
2885    if (!empty($this->lib->ical_parts)) {
2886      $this->get_ical();
2887      $this->load_itip();
2888    }
2889
2890    $html = '';
2891    $has_events = false;
2892    $ical_objects = $this->lib->get_mail_ical_objects();
2893
2894    // show a box for every event in the file
2895    foreach ($ical_objects as $idx => $event) {
2896      if ($event['_type'] != 'event')  // skip non-event objects (#2928)
2897        continue;
2898
2899      $has_events = true;
2900
2901      // get prepared inline UI for this event object
2902      if ($ical_objects->method) {
2903        $append   = '';
2904        $date_str = $this->rc->format_date($event['start'], $this->rc->config->get('date_format'), empty($event['start']->_dateonly));
2905        $date     = new DateTime($event['start']->format('Y-m-d') . ' 12:00:00', new DateTimeZone('UTC'));
2906
2907        // prepare a small agenda preview to be filled with actual event data on async request
2908        if ($ical_objects->method == 'REQUEST') {
2909          $append = html::div('calendar-agenda-preview',
2910            html::tag('h3', 'preview-title', $this->gettext('agenda') . ' ' . html::span('date', $date_str))
2911            . '%before%' . $this->mail_agenda_event_row($event, 'current') . '%after%');
2912        }
2913
2914        $html .= html::div('calendar-invitebox invitebox boxinformation',
2915          $this->itip->mail_itip_inline_ui(
2916            $event,
2917            $ical_objects->method,
2918            $ical_objects->mime_id . ':' . $idx,
2919            'calendar',
2920            rcube_utils::anytodatetime($ical_objects->message_date),
2921            $this->rc->url(array('task' => 'calendar')) . '&view=agendaDay&date=' . $date->format('U')
2922          ) . $append
2923        );
2924      }
2925
2926      // limit listing
2927      if ($idx >= 3)
2928        break;
2929    }
2930
2931    // prepend event boxes to message body
2932    if ($html) {
2933      $this->ui->init();
2934      $p['content'] = $html . $p['content'];
2935      $this->rc->output->add_label('calendar.savingdata','calendar.deleteventconfirm','calendar.declinedeleteconfirm');
2936    }
2937
2938    // add "Save to calendar" button into attachment menu
2939    if ($has_events) {
2940      $this->add_button(array(
2941        'id'         => 'attachmentsavecal',
2942        'name'       => 'attachmentsavecal',
2943        'type'       => 'link',
2944        'wrapper'    => 'li',
2945        'command'    => 'attachment-save-calendar',
2946        'class'      => 'icon calendarlink disabled',
2947        'classact'   => 'icon calendarlink active',
2948        'innerclass' => 'icon calendar',
2949        'label'      => 'calendar.savetocalendar',
2950        ), 'attachmentmenu');
2951    }
2952
2953    return $p;
2954  }
2955
2956
2957  /**
2958   * Handler for POST request to import an event attached to a mail message
2959   */
2960  public function mail_import_itip()
2961  {
2962    $itip_sending = $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']);
2963
2964    $uid     = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST);
2965    $mbox    = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST);
2966    $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST);
2967    $status  = rcube_utils::get_input_value('_status', rcube_utils::INPUT_POST);
2968    $delete  = intval(rcube_utils::get_input_value('_del', rcube_utils::INPUT_POST));
2969    $noreply = intval(rcube_utils::get_input_value('_noreply', rcube_utils::INPUT_POST));
2970    $noreply = $noreply || $status == 'needs-action' || $itip_sending === 0;
2971    $instance = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST);
2972    $savemode = rcube_utils::get_input_value('_savemode', rcube_utils::INPUT_POST);
2973    $comment  = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST);
2974
2975    $error_msg = $this->gettext('errorimportingevent');
2976    $success   = false;
2977
2978    if ($status == 'delegated') {
2979      $delegates = rcube_mime::decode_address_list(rcube_utils::get_input_value('_to', rcube_utils::INPUT_POST, true), 1, false);
2980      $delegate  = reset($delegates);
2981
2982      if (empty($delegate) || empty($delegate['mailto'])) {
2983        $this->rc->output->command('display_message', $this->rc->gettext('libcalendaring.delegateinvalidaddress'), 'error');
2984        return;
2985      }
2986    }
2987
2988    // successfully parsed events?
2989    if ($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) {
2990      // forward iTip request to delegatee
2991      if ($delegate) {
2992        $rsvpme = rcube_utils::get_input_value('_rsvp', rcube_utils::INPUT_POST);
2993        $itip   = $this->load_itip();
2994
2995        $event['comment'] = $comment;
2996
2997        if ($itip->delegate_to($event, $delegate, !empty($rsvpme))) {
2998          $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation');
2999        }
3000        else {
3001          $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
3002        }
3003
3004        unset($event['comment']);
3005
3006        // the delegator is set to non-participant, thus save as non-blocking
3007        $event['free_busy'] = 'free';
3008      }
3009
3010      $mode = calendar_driver::FILTER_PERSONAL
3011        | calendar_driver::FILTER_SHARED
3012        | calendar_driver::FILTER_WRITEABLE;
3013
3014      // find writeable calendar to store event
3015      $cal_id    = rcube_utils::get_input_value('_folder', rcube_utils::INPUT_POST);
3016      $dontsave  = $cal_id === '' && $event['_method'] == 'REQUEST';
3017      $calendars = $this->driver->list_calendars($mode);
3018      $calendar  = $calendars[$cal_id];
3019
3020      // select default calendar except user explicitly selected 'none'
3021      if (!$calendar && !$dontsave)
3022         $calendar = $this->get_default_calendar($event['sensitivity'], $calendars);
3023
3024      $metadata = array(
3025        'uid'       => $event['uid'],
3026        '_instance' => $event['_instance'],
3027        'changed'   => is_object($event['changed']) ? $event['changed']->format('U') : 0,
3028        'sequence'  => intval($event['sequence']),
3029        'fallback'  => strtoupper($status),
3030        'method'    => $event['_method'],
3031        'task'      => 'calendar',
3032      );
3033
3034      // update my attendee status according to submitted method
3035      if (!empty($status)) {
3036        $organizer = null;
3037        $emails = $this->get_user_emails();
3038        foreach ($event['attendees'] as $i => $attendee) {
3039          if ($attendee['role'] == 'ORGANIZER') {
3040            $organizer = $attendee;
3041          }
3042          else if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) {
3043            $event['attendees'][$i]['status'] = strtoupper($status);
3044            if (!in_array($event['attendees'][$i]['status'], array('NEEDS-ACTION','DELEGATED')))
3045              $event['attendees'][$i]['rsvp'] = false;  // unset RSVP attribute
3046
3047            $metadata['attendee'] = $attendee['email'];
3048            $metadata['rsvp'] = $attendee['role'] != 'NON-PARTICIPANT';
3049            $reply_sender = $attendee['email'];
3050            $event_attendee = $attendee;
3051          }
3052        }
3053
3054        // add attendee with this user's default identity if not listed
3055        if (!$reply_sender) {
3056          $sender_identity = $this->rc->user->list_emails(true);
3057          $event['attendees'][] = array(
3058            'name'   => $sender_identity['name'],
3059            'email'  => $sender_identity['email'],
3060            'role'   => 'OPT-PARTICIPANT',
3061            'status' => strtoupper($status),
3062          );
3063          $metadata['attendee'] = $sender_identity['email'];
3064        }
3065      }
3066
3067      // save to calendar
3068      if ($calendar && $calendar['editable']) {
3069        // check for existing event with the same UID
3070        $existing = $this->find_event($event, $mode);
3071
3072        // we'll create a new copy if user decided to change the calendar
3073        if ($existing && $cal_id && $calendar && $calendar['id'] != $existing['calendar']) {
3074          $existing = null;
3075        }
3076
3077        if ($existing) {
3078          $calendar = $calendars[$existing['calendar']];
3079
3080          // forward savemode for correct updates of recurring events
3081          $existing['_savemode'] = $savemode ?: $event['_savemode'];
3082
3083          // only update attendee status
3084          if ($event['_method'] == 'REPLY') {
3085            // try to identify the attendee using the email sender address
3086            $existing_attendee        = -1;
3087            $existing_attendee_emails = array();
3088
3089            foreach ($existing['attendees'] as $i => $attendee) {
3090              $existing_attendee_emails[] = $attendee['email'];
3091              if ($this->itip->compare_email($attendee['email'], $event['_sender'], $event['_sender_utf'])) {
3092                $existing_attendee = $i;
3093              }
3094            }
3095
3096            $event_attendee   = null;
3097            $update_attendees = array();
3098
3099            foreach ($event['attendees'] as $attendee) {
3100              if ($this->itip->compare_email($attendee['email'], $event['_sender'], $event['_sender_utf'])) {
3101                $event_attendee       = $attendee;
3102                $update_attendees[]   = $attendee;
3103                $metadata['fallback'] = $attendee['status'];
3104                $metadata['attendee'] = $attendee['email'];
3105                $metadata['rsvp']     = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT';
3106
3107                if ($attendee['status'] != 'DELEGATED') {
3108                  break;
3109                }
3110              }
3111              // also copy delegate attendee
3112              else if (!empty($attendee['delegated-from'])
3113                && $this->itip->compare_email($attendee['delegated-from'], $event['_sender'], $event['_sender_utf'])
3114              ) {
3115                $update_attendees[] = $attendee;
3116                if (!in_array_nocase($attendee['email'], $existing_attendee_emails)) {
3117                  $existing['attendees'][] = $attendee;
3118                }
3119              }
3120            }
3121
3122            // if delegatee has declined, set delegator's RSVP=True
3123            if ($event_attendee && $event_attendee['status'] == 'DECLINED' && $event_attendee['delegated-from']) {
3124              foreach ($existing['attendees'] as $i => $attendee) {
3125                if ($attendee['email'] == $event_attendee['delegated-from']) {
3126                  $existing['attendees'][$i]['rsvp'] = true;
3127                  break;
3128                }
3129              }
3130            }
3131
3132            // Accept sender as a new participant (different email in From: and the iTip)
3133            // Use ATTENDEE entry from the iTip with replaced email address
3134            if (!$event_attendee) {
3135              // remove the organizer
3136              $itip_attendees = array_filter($event['attendees'], function($item) { return $item['role'] != 'ORGANIZER'; });
3137
3138              // there must be only one attendee
3139              if (is_array($itip_attendees) && count($itip_attendees) == 1) {
3140                $event_attendee          = $itip_attendees[key($itip_attendees)];
3141                $event_attendee['email'] = $event['_sender'];
3142                $update_attendees[]      = $event_attendee;
3143                $metadata['fallback']    = $event_attendee['status'];
3144                $metadata['attendee']    = $event_attendee['email'];
3145                $metadata['rsvp']        = $event_attendee['rsvp'] || $event_attendee['role'] != 'NON-PARTICIPANT';
3146              }
3147            }
3148
3149            // found matching attendee entry in both existing and new events
3150            if ($existing_attendee >= 0 && $event_attendee) {
3151              $existing['attendees'][$existing_attendee] = $event_attendee;
3152              $success = $this->driver->update_attendees($existing, $update_attendees);
3153            }
3154            // update the entire attendees block
3155            else if (($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) && $event_attendee) {
3156              $existing['attendees'][] = $event_attendee;
3157              $success = $this->driver->update_attendees($existing, $update_attendees);
3158            }
3159            else if (!$event_attendee) {
3160              $error_msg = $this->gettext('errorunknownattendee');
3161            }
3162            else {
3163              $error_msg = $this->gettext('newerversionexists');
3164            }
3165          }
3166          // delete the event when declined (#1670)
3167          else if ($status == 'declined' && $delete) {
3168             $deleted = $this->driver->remove_event($existing, true);
3169             $success = true;
3170          }
3171          // import the (newer) event
3172          else if ($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) {
3173            $event['id'] = $existing['id'];
3174            $event['calendar'] = $existing['calendar'];
3175
3176            // merge attendees status
3177            // e.g. preserve my participant status for regular updates
3178            $this->lib->merge_attendees($event, $existing, $status);
3179
3180            // set status=CANCELLED on CANCEL messages
3181            if ($event['_method'] == 'CANCEL')
3182              $event['status'] = 'CANCELLED';
3183
3184            // update attachments list, allow attachments update only on REQUEST (#5342)
3185            if ($event['_method'] == 'REQUEST')
3186              $event['deleted_attachments'] = true;
3187            else
3188              unset($event['attachments']);
3189
3190            // show me as free when declined (#1670)
3191            if ($status == 'declined' || $event['status'] == 'CANCELLED' || $event_attendee['role'] == 'NON-PARTICIPANT')
3192              $event['free_busy'] = 'free';
3193
3194            $success = $this->driver->edit_event($event);
3195          }
3196          else if (!empty($status)) {
3197            $existing['attendees'] = $event['attendees'];
3198            if ($status == 'declined' || $event_attendee['role'] == 'NON-PARTICIPANT')  // show me as free when declined (#1670)
3199              $existing['free_busy'] = 'free';
3200            $success = $this->driver->edit_event($existing);
3201          }
3202          else
3203            $error_msg = $this->gettext('newerversionexists');
3204        }
3205        else if (!$existing && ($status != 'declined' || $this->rc->config->get('kolab_invitation_calendars'))) {
3206          if ($status == 'declined' || $event['status'] == 'CANCELLED' || $event_attendee['role'] == 'NON-PARTICIPANT') {
3207            $event['free_busy'] = 'free';
3208          }
3209
3210          // if the RSVP reply only refers to a single instance:
3211          // store unmodified master event with current instance as exception
3212          if (!empty($instance) && !empty($savemode) && $savemode != 'all') {
3213            $master = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event');
3214            if ($master['recurrence'] && !$master['_instance']) {
3215              // compute recurring events until this instance's date
3216              if ($recurrence_date = rcube_utils::anytodatetime($instance, $master['start']->getTimezone())) {
3217                $recurrence_date->setTime(23,59,59);
3218
3219                foreach ($this->driver->get_recurring_events($master, $master['start'], $recurrence_date) as $recurring) {
3220                  if ($recurring['_instance'] == $instance) {
3221                    // copy attendees block with my partstat to exception
3222                    $recurring['attendees'] = $event['attendees'];
3223                    $master['recurrence']['EXCEPTIONS'][] = $recurring;
3224                    $event = $recurring;  // set reference for iTip reply
3225                    break;
3226                  }
3227                }
3228
3229                $master['calendar'] = $event['calendar'] = $calendar['id'];
3230                $success = $this->driver->new_event($master);
3231              }
3232              else {
3233                $master = null;
3234              }
3235            }
3236            else {
3237              $master = null;
3238            }
3239          }
3240
3241          // save to the selected/default calendar
3242          if (!$master) {
3243            $event['calendar'] = $calendar['id'];
3244            $success = $this->driver->new_event($event);
3245          }
3246        }
3247        else if ($status == 'declined')
3248          $error_msg = null;
3249      }
3250      else if ($status == 'declined' || $dontsave)
3251        $error_msg = null;
3252      else
3253        $error_msg = $this->gettext('nowritecalendarfound');
3254    }
3255
3256    if ($success) {
3257      $message = $event['_method'] == 'REPLY' ? 'attendeupdateesuccess' : ($deleted ? 'successremoval' : ($existing ? 'updatedsuccessfully' : 'importedsuccessfully'));
3258      $this->rc->output->command('display_message', $this->gettext(array('name' => $message, 'vars' => array('calendar' => $calendar['name']))), 'confirmation');
3259    }
3260
3261    if ($success || $dontsave) {
3262      $metadata['calendar'] = $event['calendar'];
3263      $metadata['nosave'] = $dontsave;
3264      $metadata['rsvp'] = intval($metadata['rsvp']);
3265      $metadata['after_action'] = $this->rc->config->get('calendar_itip_after_action', $this->defaults['calendar_itip_after_action']);
3266      $this->rc->output->command('plugin.itip_message_processed', $metadata);
3267      $error_msg = null;
3268    }
3269    else if ($error_msg) {
3270      $this->rc->output->command('display_message', $error_msg, 'error');
3271    }
3272
3273    // send iTip reply
3274    if ($event['_method'] == 'REQUEST' && $organizer && !$noreply && !in_array(strtolower($organizer['email']), $emails) && !$error_msg) {
3275      $event['comment'] = $comment;
3276      $itip = $this->load_itip();
3277      $itip->set_sender_email($reply_sender);
3278      if ($itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status))
3279        $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation');
3280      else
3281        $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
3282    }
3283
3284    $this->rc->output->send();
3285  }
3286
3287  /**
3288   * Handler for calendar/itip-remove requests
3289   */
3290  function mail_itip_decline_reply()
3291  {
3292    $uid     = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST);
3293    $mbox    = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST);
3294    $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST);
3295
3296    if (($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) && $event['_method'] == 'REPLY') {
3297      $event['comment'] = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST);
3298
3299      foreach ($event['attendees'] as $_attendee) {
3300        if ($_attendee['role'] != 'ORGANIZER') {
3301          $attendee = $_attendee;
3302          break;
3303        }
3304      }
3305
3306      $itip = $this->load_itip();
3307      if ($itip->send_itip_message($event, 'CANCEL', $attendee, 'itipsubjectcancel', 'itipmailbodycancel'))
3308        $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $attendee['name'] ? $attendee['name'] : $attendee['email']))), 'confirmation');
3309      else
3310        $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
3311    }
3312    else {
3313      $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
3314    }
3315  }
3316
3317  /**
3318   * Handler for calendar/itip-delegate requests
3319   */
3320  function mail_itip_delegate()
3321  {
3322    // forward request to mail_import_itip() with the right status
3323    $_POST['_status'] = $_REQUEST['_status'] = 'delegated';
3324    $this->mail_import_itip();
3325  }
3326
3327  /**
3328   * Import the full payload from a mail message attachment
3329   */
3330  public function mail_import_attachment()
3331  {
3332    $uid     = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST);
3333    $mbox    = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST);
3334    $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST);
3335    $charset = RCUBE_CHARSET;
3336
3337    // establish imap connection
3338    $imap = $this->rc->get_storage();
3339    $imap->set_folder($mbox);
3340
3341    if ($uid && $mime_id) {
3342      $part = $imap->get_message_part($uid, $mime_id);
3343      if ($part->ctype_parameters['charset'])
3344        $charset = $part->ctype_parameters['charset'];
3345//      $headers = $imap->get_message_headers($uid);
3346
3347      if ($part) {
3348        $events = $this->get_ical()->import($part, $charset);
3349      }
3350    }
3351
3352    $success = $existing = 0;
3353    if (!empty($events)) {
3354      // find writeable calendar to store event
3355      $cal_id = !empty($_REQUEST['_calendar']) ? rcube_utils::get_input_value('_calendar', rcube_utils::INPUT_POST) : null;
3356      $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL);
3357
3358      foreach ($events as $event) {
3359        // save to calendar
3360        $calendar = $calendars[$cal_id] ?: $this->get_default_calendar($event['sensitivity']);
3361        if ($calendar && $calendar['editable'] && $event['_type'] == 'event') {
3362          $event['calendar'] = $calendar['id'];
3363
3364          if (!$this->driver->get_event($event['uid'], calendar_driver::FILTER_WRITEABLE)) {
3365            $success += (bool)$this->driver->new_event($event);
3366          }
3367          else {
3368            $existing++;
3369          }
3370        }
3371      }
3372    }
3373
3374    if ($success) {
3375      $this->rc->output->command('display_message', $this->gettext(array(
3376        'name' => 'importsuccess',
3377        'vars' => array('nr' => $success),
3378      )), 'confirmation');
3379    }
3380    else if ($existing) {
3381      $this->rc->output->command('display_message', $this->gettext('importwarningexists'), 'warning');
3382    }
3383    else {
3384      $this->rc->output->command('display_message', $this->gettext('errorimportingevent'), 'error');
3385    }
3386  }
3387
3388  /**
3389   * Read email message and return contents for a new event based on that message
3390   */
3391  public function mail_message2event()
3392  {
3393    $this->ui->init();
3394    $this->ui->addJS();
3395    $this->ui->init_templates();
3396    $this->ui->calendar_list(array(), true); // set env['calendars']
3397
3398    $uid   = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GET);
3399    $mbox  = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GET);
3400    $event = array();
3401
3402    // establish imap connection
3403    $imap    = $this->rc->get_storage();
3404    $message = new rcube_message($uid, $mbox);
3405
3406    if ($message->headers) {
3407      $event['title']       = trim($message->subject);
3408      $event['description'] = trim($message->first_text_part());
3409
3410      $this->load_driver();
3411
3412      // add a reference to the email message
3413      if ($msgref = $this->driver->get_message_reference($message->headers, $mbox)) {
3414        $event['links'] = array($msgref);
3415      }
3416      // copy mail attachments to event
3417      else if ($message->attachments) {
3418        $eventid = 'cal-';
3419        if (!is_array($_SESSION[self::SESSION_KEY]) || $_SESSION[self::SESSION_KEY]['id'] != $eventid) {
3420          $_SESSION[self::SESSION_KEY] = array();
3421          $_SESSION[self::SESSION_KEY]['id'] = $eventid;
3422          $_SESSION[self::SESSION_KEY]['attachments'] = array();
3423        }
3424
3425        foreach ((array)$message->attachments as $part) {
3426          $attachment = array(
3427            'data' => $imap->get_message_part($uid, $part->mime_id, $part),
3428            'size' => $part->size,
3429            'name' => $part->filename,
3430            'mimetype' => $part->mimetype,
3431            'group' => $eventid,
3432          );
3433
3434          $attachment = $this->rc->plugins->exec_hook('attachment_save', $attachment);
3435
3436          if ($attachment['status'] && !$attachment['abort']) {
3437            $id = $attachment['id'];
3438            $attachment['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']);
3439
3440            // store new attachment in session
3441            unset($attachment['status'], $attachment['abort'], $attachment['data']);
3442            $_SESSION[self::SESSION_KEY]['attachments'][$id] = $attachment;
3443
3444            $attachment['id'] = 'rcmfile' . $attachment['id'];  // add prefix to consider it 'new'
3445            $event['attachments'][] = $attachment;
3446          }
3447        }
3448      }
3449
3450      $this->rc->output->set_env('event_prop', $event);
3451    }
3452    else {
3453      $this->rc->output->command('display_message', $this->gettext('messageopenerror'), 'error');
3454    }
3455
3456    $this->rc->output->send('calendar.dialog');
3457  }
3458
3459  /**
3460   * Handler for the 'message_compose' plugin hook. This will check for
3461   * a compose parameter 'calendar_event' and create an attachment with the
3462   * referenced event in iCal format
3463   */
3464  public function mail_message_compose($args)
3465  {
3466    // set the submitted event ID as attachment
3467    if (!empty($args['param']['calendar_event'])) {
3468      $this->load_driver();
3469
3470      list($cal, $id) = explode(':', $args['param']['calendar_event'], 2);
3471      if ($event = $this->driver->get_event(array('id' => $id, 'calendar' => $cal))) {
3472        $filename = asciiwords($event['title']);
3473        if (empty($filename))
3474          $filename = 'event';
3475
3476        // save ics to a temp file and register as attachment
3477        $tmp_path = tempnam($this->rc->config->get('temp_dir'), 'rcmAttmntCal');
3478        file_put_contents($tmp_path, $this->get_ical()->export(array($event), '', false, array($this->driver, 'get_attachment_body')));
3479
3480        $args['attachments'][] = array(
3481          'path'     => $tmp_path,
3482          'name'     => $filename . '.ics',
3483          'mimetype' => 'text/calendar',
3484          'size'     => filesize($tmp_path),
3485        );
3486        $args['param']['subject'] = $event['title'];
3487      }
3488    }
3489
3490    return $args;
3491  }
3492
3493
3494  /**
3495   * Get a list of email addresses of the current user (from login and identities)
3496   */
3497  public function get_user_emails()
3498  {
3499    return $this->lib->get_user_emails();
3500  }
3501
3502
3503  /**
3504   * Build an absolute URL with the given parameters
3505   */
3506  public function get_url($param = array())
3507  {
3508    $param += array('task' => 'calendar');
3509    return $this->rc->url($param, true, true);
3510  }
3511
3512
3513  public function ical_feed_hash($source)
3514  {
3515    return base64_encode($this->rc->user->get_username() . ':' . $source);
3516  }
3517
3518  /**
3519   * Handler for user_delete plugin hook
3520   */
3521  public function user_delete($args)
3522  {
3523     // delete itipinvitations entries related to this user
3524     $db = $this->rc->get_dbh();
3525     $table_itipinvitations = $db->table_name('itipinvitations', true);
3526     $db->query("DELETE FROM $table_itipinvitations WHERE `user_id` = ?", $args['user']->ID);
3527
3528     $this->setup();
3529     $this->load_driver();
3530     return $this->driver->user_delete($args);
3531  }
3532
3533  /**
3534   * Find first occurrence of a recurring event excluding start date
3535   *
3536   * @param array $event Event data (with 'start' and 'recurrence')
3537   *
3538   * @return DateTime Date of the first occurrence
3539   */
3540  public function find_first_occurrence($event)
3541  {
3542    // Make sure libkolab plugin is loaded in case of Kolab driver
3543    $this->load_driver();
3544
3545    // Use libkolab to compute recurring events (and libkolab plugin)
3546    // Horde-based fallback has many bugs
3547    if (class_exists('kolabformat') && class_exists('kolabcalendaring') && class_exists('kolab_date_recurrence')) {
3548      $object = kolab_format::factory('event', 3.0);
3549      $object->set($event);
3550
3551      $recurrence = new kolab_date_recurrence($object);
3552    }
3553    else {
3554      // fallback to libcalendaring (Horde-based) recurrence implementation
3555      require_once(__DIR__ . '/lib/calendar_recurrence.php');
3556      $recurrence = new calendar_recurrence($this, $event);
3557    }
3558
3559    return $recurrence->first_occurrence();
3560  }
3561
3562  /**
3563   * Get date-time input from UI and convert to unix timestamp
3564   */
3565  protected function input_timestamp($name, $type)
3566  {
3567    $ts = rcube_utils::get_input_value($name, $type);
3568
3569    if ($ts && (!is_numeric($ts) || strpos($ts, 'T'))) {
3570      $ts = new DateTime($ts, $this->timezone);
3571      $ts = $ts->getTimestamp();
3572    }
3573
3574    return $ts;
3575  }
3576
3577  /**
3578   * Magic getter for public access to protected members
3579   */
3580  public function __get($name)
3581  {
3582    switch ($name) {
3583      case 'ical':
3584        return $this->get_ical();
3585
3586      case 'itip':
3587        return $this->load_itip();
3588
3589      case 'driver':
3590        $this->load_driver();
3591        return $this->driver;
3592    }
3593
3594    return null;
3595  }
3596
3597}
3598