1<?php 2 3/** 4 * Kolab driver for the Calendar plugin 5 * 6 * @version @package_version@ 7 * @author Thomas Bruederli <bruederli@kolabsys.com> 8 * @author Aleksander Machniak <machniak@kolabsys.com> 9 * 10 * Copyright (C) 2012-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 kolab_driver extends calendar_driver 27{ 28 const INVITATIONS_CALENDAR_PENDING = '--invitation--pending'; 29 const INVITATIONS_CALENDAR_DECLINED = '--invitation--declined'; 30 31 // features this backend supports 32 public $alarms = true; 33 public $attendees = true; 34 public $freebusy = true; 35 public $attachments = true; 36 public $undelete = true; 37 public $alarm_types = array('DISPLAY','AUDIO'); 38 public $categoriesimmutable = true; 39 40 private $rc; 41 private $cal; 42 private $calendars; 43 private $has_writeable = false; 44 private $freebusy_trigger = false; 45 private $bonnie_api = false; 46 47 /** 48 * Default constructor 49 */ 50 public function __construct($cal) 51 { 52 $cal->require_plugin('libkolab'); 53 54 // load helper classes *after* libkolab has been loaded (#3248) 55 require_once(dirname(__FILE__) . '/kolab_calendar.php'); 56 require_once(dirname(__FILE__) . '/kolab_user_calendar.php'); 57 require_once(dirname(__FILE__) . '/kolab_invitation_calendar.php'); 58 59 $this->cal = $cal; 60 $this->rc = $cal->rc; 61 62 $this->cal->register_action('push-freebusy', array($this, 'push_freebusy')); 63 $this->cal->register_action('calendar-acl', array($this, 'calendar_acl')); 64 65 $this->freebusy_trigger = $this->rc->config->get('calendar_freebusy_trigger', false); 66 67 if (kolab_storage::$version == '2.0') { 68 $this->alarm_types = array('DISPLAY'); 69 $this->alarm_absolute = false; 70 } 71 72 // get configuration for the Bonnie API 73 $this->bonnie_api = libkolab::get_bonnie_api(); 74 75 // calendar uses fully encoded identifiers 76 kolab_storage::$encode_ids = true; 77 } 78 79 80 /** 81 * Read available calendars from server 82 */ 83 private function _read_calendars() 84 { 85 // already read sources 86 if (isset($this->calendars)) 87 return $this->calendars; 88 89 // get all folders that have "event" type, sorted by namespace/name 90 $folders = kolab_storage::sort_folders(kolab_storage::get_folders('event') + kolab_storage::get_user_folders('event', true)); 91 92 $this->calendars = array(); 93 foreach ($folders as $folder) { 94 $calendar = $this->_to_calendar($folder); 95 if ($calendar->ready) { 96 $this->calendars[$calendar->id] = $calendar; 97 if ($calendar->editable) { 98 $this->has_writeable = true; 99 } 100 } 101 } 102 103 return $this->calendars; 104 } 105 106 /** 107 * Convert kolab_storage_folder into kolab_calendar 108 */ 109 private function _to_calendar($folder) 110 { 111 if ($folder instanceof kolab_calendar) { 112 return $folder; 113 } 114 115 if ($folder instanceof kolab_storage_folder_user) { 116 $calendar = new kolab_user_calendar($folder, $this->cal); 117 $calendar->subscriptions = count($folder->children) > 0; 118 } 119 else { 120 $calendar = new kolab_calendar($folder->name, $this->cal); 121 } 122 123 return $calendar; 124 } 125 126 /** 127 * Get a list of available calendars from this source 128 * 129 * @param integer $filter Bitmask defining filter criterias 130 * @param object $tree Reference to hierarchical folder tree object 131 * 132 * @return array List of calendars 133 */ 134 public function list_calendars($filter = 0, &$tree = null) 135 { 136 $this->_read_calendars(); 137 138 // attempt to create a default calendar for this user 139 if (!$this->has_writeable) { 140 if ($this->create_calendar(array('name' => 'Calendar', 'color' => 'cc0000'))) { 141 unset($this->calendars); 142 $this->_read_calendars(); 143 } 144 } 145 146 $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); 147 $folders = $this->filter_calendars($filter); 148 $calendars = array(); 149 150 // include virtual folders for a full folder tree 151 if (!is_null($tree)) 152 $folders = kolab_storage::folder_hierarchy($folders, $tree); 153 154 $parents = array_keys($this->calendars); 155 156 foreach ($folders as $id => $cal) { 157 $imap_path = explode($delim, $cal->name); 158 159 // find parent 160 do { 161 array_pop($imap_path); 162 $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); 163 } 164 while (count($imap_path) > 1 && !in_array($parent_id, $parents)); 165 166 // restore "real" parent ID 167 if ($parent_id && !in_array($parent_id, $parents)) { 168 $parent_id = kolab_storage::folder_id($cal->get_parent()); 169 } 170 171 $parents[] = $cal->id; 172 173 if ($cal->virtual) { 174 $calendars[$cal->id] = array( 175 'id' => $cal->id, 176 'name' => $cal->get_name(), 177 'listname' => $cal->get_foldername(), 178 'editname' => $cal->get_foldername(), 179 'virtual' => true, 180 'editable' => false, 181 'group' => $cal->get_namespace(), 182 ); 183 } 184 else { 185 // additional folders may come from kolab_storage::folder_hierarchy() above 186 // make sure we deal with kolab_calendar instances 187 $cal = $this->_to_calendar($cal); 188 $this->calendars[$cal->id] = $cal; 189 190 $is_user = ($cal instanceof kolab_user_calendar); 191 192 $calendars[$cal->id] = array( 193 'id' => $cal->id, 194 'name' => $cal->get_name(), 195 'listname' => $cal->get_foldername(), 196 'editname' => $cal->get_foldername(), 197 'title' => $cal->get_title(), 198 'color' => $cal->get_color(), 199 'editable' => $cal->editable, 200 'group' => $is_user ? 'other user' : $cal->get_namespace(), 201 'active' => $cal->is_active(), 202 'owner' => $cal->get_owner(), 203 'removable' => !$cal->default, 204 ); 205 206 if (!$is_user) { 207 $calendars[$cal->id] += array( 208 'default' => $cal->default, 209 'rights' => $cal->rights, 210 'showalarms' => $cal->alarms, 211 'history' => !empty($this->bonnie_api), 212 'children' => true, // TODO: determine if that folder indeed has child folders 213 'parent' => $parent_id, 214 'subtype' => $cal->subtype, 215 'caldavurl' => $cal->get_caldav_url(), 216 ); 217 } 218 } 219 220 if ($cal->subscriptions) { 221 $calendars[$cal->id]['subscribed'] = $cal->is_subscribed(); 222 } 223 } 224 225 // list virtual calendars showing invitations 226 if ($this->rc->config->get('kolab_invitation_calendars') && !($filter & self::FILTER_INSERTABLE)) { 227 foreach (array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED) as $id) { 228 $cal = new kolab_invitation_calendar($id, $this->cal); 229 if (!($filter & self::FILTER_ACTIVE) || $cal->is_active()) { 230 $calendars[$id] = array( 231 'id' => $cal->id, 232 'name' => $cal->get_name(), 233 'listname' => $cal->get_name(), 234 'editname' => $cal->get_foldername(), 235 'title' => $cal->get_title(), 236 'color' => $cal->get_color(), 237 'editable' => $cal->editable, 238 'rights' => $cal->rights, 239 'showalarms' => $cal->alarms, 240 'history' => !empty($this->bonnie_api), 241 'group' => 'x-invitations', 242 'default' => false, 243 'active' => $cal->is_active(), 244 'owner' => $cal->get_owner(), 245 'children' => false, 246 ); 247 248 if ($id == self::INVITATIONS_CALENDAR_PENDING) { 249 $calendars[$id]['counts'] = true; 250 } 251 252 if (is_object($tree)) { 253 $tree->children[] = $cal; 254 } 255 } 256 } 257 } 258 259 // append the virtual birthdays calendar 260 if ($this->rc->config->get('calendar_contact_birthdays', false) && !($filter & self::FILTER_INSERTABLE)) { 261 $id = self::BIRTHDAY_CALENDAR_ID; 262 $prefs = $this->rc->config->get('kolab_calendars', array()); // read local prefs 263 if (!($filter & self::FILTER_ACTIVE) || $prefs[$id]['active']) { 264 $calendars[$id] = array( 265 'id' => $id, 266 'name' => $this->cal->gettext('birthdays'), 267 'listname' => $this->cal->gettext('birthdays'), 268 'color' => $prefs[$id]['color'] ?: '87CEFA', 269 'active' => (bool)$prefs[$id]['active'], 270 'showalarms' => (bool)$this->rc->config->get('calendar_birthdays_alarm_type'), 271 'group' => 'x-birthdays', 272 'editable' => false, 273 'default' => false, 274 'children' => false, 275 'history' => false, 276 ); 277 } 278 } 279 280 return $calendars; 281 } 282 283 /** 284 * Get list of calendars according to specified filters 285 * 286 * @param integer Bitmask defining restrictions. See FILTER_* constants for possible values. 287 * 288 * @return array List of calendars 289 */ 290 protected function filter_calendars($filter) 291 { 292 $this->_read_calendars(); 293 294 $calendars = array(); 295 296 $plugin = $this->rc->plugins->exec_hook('calendar_list_filter', array( 297 'list' => $this->calendars, 298 'calendars' => $calendars, 299 'filter' => $filter, 300 )); 301 302 if ($plugin['abort']) { 303 return $plugin['calendars']; 304 } 305 306 $personal = $filter & self::FILTER_PERSONAL; 307 $shared = $filter & self::FILTER_SHARED; 308 309 foreach ($this->calendars as $cal) { 310 if (!$cal->ready) { 311 continue; 312 } 313 if (($filter & self::FILTER_WRITEABLE) && !$cal->editable) { 314 continue; 315 } 316 if (($filter & self::FILTER_INSERTABLE) && !$cal->editable) { 317 continue; 318 } 319 if (($filter & self::FILTER_ACTIVE) && !$cal->is_active()) { 320 continue; 321 } 322 if (($filter & self::FILTER_PRIVATE) && $cal->subtype != 'private') { 323 continue; 324 } 325 if (($filter & self::FILTER_CONFIDENTIAL) && $cal->subtype != 'confidential') { 326 continue; 327 } 328 if ($personal || $shared) { 329 $ns = $cal->get_namespace(); 330 if (!(($personal && $ns == 'personal') || ($shared && $ns == 'shared'))) { 331 continue; 332 } 333 } 334 335 $calendars[$cal->id] = $cal; 336 } 337 338 return $calendars; 339 } 340 341 /** 342 * Get the kolab_calendar instance for the given calendar ID 343 * 344 * @param string Calendar identifier (encoded imap folder name) 345 * 346 * @return object kolab_calendar Object nor null if calendar doesn't exist 347 */ 348 public function get_calendar($id) 349 { 350 $this->_read_calendars(); 351 352 // create calendar object if necesary 353 if (!$this->calendars[$id]) { 354 if (in_array($id, array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { 355 return new kolab_invitation_calendar($id, $this->cal); 356 } 357 // for unsubscribed calendar folders 358 if ($id !== self::BIRTHDAY_CALENDAR_ID) { 359 $calendar = kolab_calendar::factory($id, $this->cal); 360 if ($calendar->ready) { 361 $this->calendars[$calendar->id] = $calendar; 362 } 363 } 364 } 365 366 return $this->calendars[$id]; 367 } 368 369 /** 370 * Create a new calendar assigned to the current user 371 * 372 * @param array Hash array with calendar properties 373 * name: Calendar name 374 * color: The color of the calendar 375 * 376 * @return mixed ID of the calendar on success, False on error 377 */ 378 public function create_calendar($prop) 379 { 380 $prop['type'] = 'event'; 381 $prop['active'] = true; 382 $prop['subscribed'] = true; 383 384 $folder = kolab_storage::folder_update($prop); 385 386 if ($folder === false) { 387 $this->last_error = $this->cal->gettext(kolab_storage::$last_error); 388 return false; 389 } 390 391 // create ID 392 $id = kolab_storage::folder_id($folder); 393 394 // save color in user prefs (temp. solution) 395 $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); 396 397 if (isset($prop['color'])) 398 $prefs['kolab_calendars'][$id]['color'] = $prop['color']; 399 if (isset($prop['showalarms'])) 400 $prefs['kolab_calendars'][$id]['showalarms'] = $prop['showalarms'] ? true : false; 401 402 if ($prefs['kolab_calendars'][$id]) 403 $this->rc->user->save_prefs($prefs); 404 405 return $id; 406 } 407 408 409 /** 410 * Update properties of an existing calendar 411 * 412 * @see calendar_driver::edit_calendar() 413 */ 414 public function edit_calendar($prop) 415 { 416 if ($prop['id'] && ($cal = $this->get_calendar($prop['id']))) { 417 $id = $cal->update($prop); 418 } 419 else { 420 $id = $prop['id']; 421 } 422 423 // fallback to local prefs 424 $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); 425 unset($prefs['kolab_calendars'][$prop['id']]['color'], $prefs['kolab_calendars'][$prop['id']]['showalarms']); 426 427 if (isset($prop['color'])) 428 $prefs['kolab_calendars'][$id]['color'] = $prop['color']; 429 430 if (isset($prop['showalarms']) && $id == self::BIRTHDAY_CALENDAR_ID) 431 $prefs['calendar_birthdays_alarm_type'] = $prop['showalarms'] ? $this->alarm_types[0] : ''; 432 else if (isset($prop['showalarms'])) 433 $prefs['kolab_calendars'][$id]['showalarms'] = $prop['showalarms'] ? true : false; 434 435 if (!empty($prefs['kolab_calendars'][$id])) 436 $this->rc->user->save_prefs($prefs); 437 438 return true; 439 } 440 441 442 /** 443 * Set active/subscribed state of a calendar 444 * 445 * @see calendar_driver::subscribe_calendar() 446 */ 447 public function subscribe_calendar($prop) 448 { 449 if ($prop['id'] && ($cal = $this->get_calendar($prop['id'])) && is_object($cal->storage)) { 450 $ret = false; 451 if (isset($prop['permanent'])) 452 $ret |= $cal->storage->subscribe(intval($prop['permanent'])); 453 if (isset($prop['active'])) 454 $ret |= $cal->storage->activate(intval($prop['active'])); 455 456 // apply to child folders, too 457 if ($prop['recursive']) { 458 foreach ((array)kolab_storage::list_folders($cal->storage->name, '*', 'event') as $subfolder) { 459 if (isset($prop['permanent'])) 460 ($prop['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder)); 461 if (isset($prop['active'])) 462 ($prop['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder)); 463 } 464 } 465 return $ret; 466 } 467 else { 468 // save state in local prefs 469 $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); 470 $prefs['kolab_calendars'][$prop['id']]['active'] = (bool)$prop['active']; 471 $this->rc->user->save_prefs($prefs); 472 return true; 473 } 474 475 return false; 476 } 477 478 479 /** 480 * Delete the given calendar with all its contents 481 * 482 * @see calendar_driver::delete_calendar() 483 */ 484 public function delete_calendar($prop) 485 { 486 if ($prop['id'] && ($cal = $this->get_calendar($prop['id']))) { 487 $folder = $cal->get_realname(); 488 // TODO: unsubscribe if no admin rights 489 if (kolab_storage::folder_delete($folder)) { 490 // remove color in user prefs (temp. solution) 491 $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); 492 unset($prefs['kolab_calendars'][$prop['id']]); 493 494 $this->rc->user->save_prefs($prefs); 495 return true; 496 } 497 else 498 $this->last_error = kolab_storage::$last_error; 499 } 500 501 return false; 502 } 503 504 505 /** 506 * Search for shared or otherwise not listed calendars the user has access 507 * 508 * @param string Search string 509 * @param string Section/source to search 510 * @return array List of calendars 511 */ 512 public function search_calendars($query, $source) 513 { 514 if (!kolab_storage::setup()) 515 return array(); 516 517 $this->calendars = array(); 518 $this->search_more_results = false; 519 520 // find unsubscribed IMAP folders that have "event" type 521 if ($source == 'folders') { 522 foreach ((array)kolab_storage::search_folders('event', $query, array('other')) as $folder) { 523 $calendar = new kolab_calendar($folder->name, $this->cal); 524 $this->calendars[$calendar->id] = $calendar; 525 } 526 } 527 // find other user's virtual calendars 528 else if ($source == 'users') { 529 $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number 530 foreach (kolab_storage::search_users($query, 0, array(), $limit, $count) as $user) { 531 $calendar = new kolab_user_calendar($user, $this->cal); 532 $this->calendars[$calendar->id] = $calendar; 533 534 // search for calendar folders shared by this user 535 foreach (kolab_storage::list_user_folders($user, 'event', false) as $foldername) { 536 $cal = new kolab_calendar($foldername, $this->cal); 537 $this->calendars[$cal->id] = $cal; 538 $calendar->subscriptions = true; 539 } 540 } 541 542 if ($count > $limit) { 543 $this->search_more_results = true; 544 } 545 } 546 547 // don't list the birthday calendar 548 $this->rc->config->set('calendar_contact_birthdays', false); 549 $this->rc->config->set('kolab_invitation_calendars', false); 550 551 return $this->list_calendars(); 552 } 553 554 555 /** 556 * Fetch a single event 557 * 558 * @see calendar_driver::get_event() 559 * @return array Hash array with event properties, false if not found 560 */ 561 public function get_event($event, $scope = 0, $full = false) 562 { 563 if (is_array($event)) { 564 $id = $event['id'] ?: $event['uid']; 565 $cal = $event['calendar']; 566 567 // we're looking for a recurring instance: expand the ID to our internal convention for recurring instances 568 if (!$event['id'] && $event['_instance']) { 569 $id .= '-' . $event['_instance']; 570 } 571 } 572 else { 573 $id = $event; 574 } 575 576 if ($cal) { 577 if ($storage = $this->get_calendar($cal)) { 578 $result = $storage->get_event($id); 579 return self::to_rcube_event($result); 580 } 581 // get event from the address books birthday calendar 582 else if ($cal == self::BIRTHDAY_CALENDAR_ID) { 583 return $this->get_birthday_event($id); 584 } 585 } 586 // iterate over all calendar folders and search for the event ID 587 else { 588 foreach ($this->filter_calendars($scope) as $calendar) { 589 if ($result = $calendar->get_event($id)) { 590 return self::to_rcube_event($result); 591 } 592 } 593 } 594 595 return false; 596 } 597 598 /** 599 * Add a single event to the database 600 * 601 * @see calendar_driver::new_event() 602 */ 603 public function new_event($event) 604 { 605 if (!$this->validate($event)) 606 return false; 607 608 $event = self::from_rcube_event($event); 609 610 if (!$event['calendar']) { 611 $this->_read_calendars(); 612 $event['calendar'] = reset(array_keys($this->calendars)); 613 } 614 615 if ($storage = $this->get_calendar($event['calendar'])) { 616 // if this is a recurrence instance, append as exception to an already existing object for this UID 617 if (!empty($event['recurrence_date']) && ($master = $storage->get_event($event['uid']))) { 618 self::add_exception($master, $event); 619 $success = $storage->update_event($master); 620 } 621 else { 622 $success = $storage->insert_event($event); 623 } 624 625 if ($success && $this->freebusy_trigger) { 626 $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); 627 $this->freebusy_trigger = false; // disable after first execution (#2355) 628 } 629 630 return $success; 631 } 632 633 return false; 634 } 635 636 /** 637 * Update an event entry with the given data 638 * 639 * @see calendar_driver::new_event() 640 * @return boolean True on success, False on error 641 */ 642 public function edit_event($event) 643 { 644 if (!($storage = $this->get_calendar($event['calendar']))) 645 return false; 646 647 return $this->update_event(self::from_rcube_event($event, $storage->get_event($event['id']))); 648 } 649 650 /** 651 * Extended event editing with possible changes to the argument 652 * 653 * @param array Hash array with event properties 654 * @param string New participant status 655 * @param array List of hash arrays with updated attendees 656 * @return boolean True on success, False on error 657 */ 658 public function edit_rsvp(&$event, $status, $attendees) 659 { 660 $update_event = $event; 661 662 // apply changes to master (and all exceptions) 663 if ($event['_savemode'] == 'all' && $event['recurrence_id']) { 664 if ($storage = $this->get_calendar($event['calendar'])) { 665 $update_event = $storage->get_event($event['recurrence_id']); 666 $update_event['_savemode'] = $event['_savemode']; 667 $update_event['id'] = $update_event['uid']; 668 unset($update_event['recurrence_id']); 669 calendar::merge_attendee_data($update_event, $attendees); 670 } 671 } 672 673 if ($ret = $this->update_attendees($update_event, $attendees)) { 674 // replace with master event (for iTip reply) 675 $event = self::to_rcube_event($update_event); 676 677 // re-assign to the according (virtual) calendar 678 if ($this->rc->config->get('kolab_invitation_calendars')) { 679 if (strtoupper($status) == 'DECLINED') 680 $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; 681 else if (strtoupper($status) == 'NEEDS-ACTION') 682 $event['calendar'] = self::INVITATIONS_CALENDAR_PENDING; 683 else if ($event['_folder_id']) 684 $event['calendar'] = $event['_folder_id']; 685 } 686 } 687 688 return $ret; 689 } 690 691 /** 692 * Update the participant status for the given attendees 693 * 694 * @see calendar_driver::update_attendees() 695 */ 696 public function update_attendees(&$event, $attendees) 697 { 698 // for this-and-future updates, merge the updated attendees onto all exceptions in range 699 if (($event['_savemode'] == 'future' && $event['recurrence_id']) || (!empty($event['recurrence']) && !$event['recurrence_id'])) { 700 if (!($storage = $this->get_calendar($event['calendar']))) 701 return false; 702 703 // load master event 704 $master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event; 705 706 // apply attendee update to each existing exception 707 if ($master['recurrence'] && !empty($master['recurrence']['EXCEPTIONS'])) { 708 $saved = false; 709 foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { 710 // merge the new event properties onto future exceptions 711 if ($exception['_instance'] >= strval($event['_instance'])) { 712 calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $attendees); 713 } 714 // update a specific instance 715 if ($exception['_instance'] == $event['_instance'] && $exception['thisandfuture']) { 716 $saved = true; 717 } 718 } 719 720 // add the given event as new exception 721 if (!$saved && $event['id'] != $master['id']) { 722 $event['thisandfuture'] = true; 723 $master['recurrence']['EXCEPTIONS'][] = $event; 724 } 725 726 // set link to top-level exceptions 727 $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; 728 729 return $this->update_event($master); 730 } 731 } 732 733 // just update the given event (instance) 734 return $this->update_event($event); 735 } 736 737 /** 738 * Move a single event 739 * 740 * @see calendar_driver::move_event() 741 * @return boolean True on success, False on error 742 */ 743 public function move_event($event) 744 { 745 if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { 746 unset($ev['sequence']); 747 self::clear_attandee_noreply($ev); 748 return $this->update_event($event + $ev); 749 } 750 751 return false; 752 } 753 754 /** 755 * Resize a single event 756 * 757 * @see calendar_driver::resize_event() 758 * @return boolean True on success, False on error 759 */ 760 public function resize_event($event) 761 { 762 if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { 763 unset($ev['sequence']); 764 self::clear_attandee_noreply($ev); 765 return $this->update_event($event + $ev); 766 } 767 768 return false; 769 } 770 771 /** 772 * Remove a single event 773 * 774 * @param array Hash array with event properties: 775 * id: Event identifier 776 * @param boolean Remove record(s) irreversible (mark as deleted otherwise) 777 * 778 * @return boolean True on success, False on error 779 */ 780 public function remove_event($event, $force = true) 781 { 782 $ret = true; 783 $success = false; 784 $savemode = $event['_savemode']; 785 $decline = $event['_decline']; 786 787 if (!$force) { 788 unset($event['attendees']); 789 $this->rc->session->remove('calendar_event_undo'); 790 $this->rc->session->remove('calendar_restore_event_data'); 791 $sess_data = $event; 792 } 793 794 if (($storage = $this->get_calendar($event['calendar'])) && ($event = $storage->get_event($event['id']))) { 795 $event['_savemode'] = $savemode; 796 $savemode = 'all'; 797 $master = $event; 798 799 // read master if deleting a recurring event 800 if ($event['recurrence'] || $event['recurrence_id'] || $event['isexception']) { 801 $master = $storage->get_event($event['uid']); 802 $savemode = $event['_savemode'] ?: ($event['_instance'] || $event['isexception'] ? 'current' : 'all'); 803 804 // force 'current' mode for single occurrences stored as exception 805 if (!$event['recurrence'] && !$event['recurrence_id'] && $event['isexception']) 806 $savemode = 'current'; 807 } 808 809 // removing an exception instance 810 if (($event['recurrence_id'] || $event['isexception']) && is_array($master['exceptions'])) { 811 foreach ($master['exceptions'] as $i => $exception) { 812 if ($exception['_instance'] == $event['_instance']) { 813 unset($master['exceptions'][$i]); 814 // set event date back to the actual occurrence 815 if ($exception['recurrence_date']) 816 $event['start'] = $exception['recurrence_date']; 817 } 818 } 819 820 if (is_array($master['recurrence'])) { 821 $master['recurrence']['EXCEPTIONS'] = &$master['exceptions']; 822 } 823 } 824 825 switch ($savemode) { 826 case 'current': 827 $_SESSION['calendar_restore_event_data'] = $master; 828 829 // remove the matching RDATE entry 830 if ($master['recurrence']['RDATE']) { 831 foreach ($master['recurrence']['RDATE'] as $j => $rdate) { 832 if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { 833 unset($master['recurrence']['RDATE'][$j]); 834 break; 835 } 836 } 837 } 838 839 // add exception to master event 840 $master['recurrence']['EXDATE'][] = $event['start']; 841 842 $success = $storage->update_event($master); 843 break; 844 845 case 'future': 846 $master['_instance'] = libcalendaring::recurrence_instance_identifier($master); 847 if ($master['_instance'] != $event['_instance']) { 848 $_SESSION['calendar_restore_event_data'] = $master; 849 850 // set until-date on master event 851 $master['recurrence']['UNTIL'] = clone $event['start']; 852 $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); 853 unset($master['recurrence']['COUNT']); 854 855 // if all future instances are deleted, remove recurrence rule entirely (bug #1677) 856 if ($master['recurrence']['UNTIL']->format('Ymd') == $master['start']->format('Ymd')) { 857 $master['recurrence'] = array(); 858 } 859 // remove matching RDATE entries 860 else if ($master['recurrence']['RDATE']) { 861 foreach ($master['recurrence']['RDATE'] as $j => $rdate) { 862 if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { 863 $master['recurrence']['RDATE'] = array_slice($master['recurrence']['RDATE'], 0, $j); 864 break; 865 } 866 } 867 } 868 869 $success = $storage->update_event($master); 870 $ret = $master['uid']; 871 break; 872 } 873 874 default: // 'all' is default 875 // removing the master event with loose exceptions (not recurring though) 876 if (!empty($event['recurrence_date']) && empty($master['recurrence']) && !empty($master['exceptions'])) { 877 // make the first exception the new master 878 $newmaster = array_shift($master['exceptions']); 879 $newmaster['exceptions'] = $master['exceptions']; 880 $newmaster['_attachments'] = $master['_attachments']; 881 $newmaster['_mailbox'] = $master['_mailbox']; 882 $newmaster['_msguid'] = $master['_msguid']; 883 884 $success = $storage->update_event($newmaster); 885 } 886 else if ($decline && $this->rc->config->get('kolab_invitation_calendars')) { 887 // don't delete but set PARTSTAT=DECLINED 888 if ($this->cal->lib->set_partstat($master, 'DECLINED')) { 889 $success = $storage->update_event($master); 890 } 891 } 892 893 if (!$success) 894 $success = $storage->delete_event($master, $force); 895 break; 896 } 897 } 898 899 if ($success && !$force) { 900 if ($master['_folder_id']) 901 $sess_data['_folder_id'] = $master['_folder_id']; 902 $_SESSION['calendar_event_undo'] = array('ts' => time(), 'data' => $sess_data); 903 } 904 905 if ($success && $this->freebusy_trigger) 906 $this->rc->output->command('plugin.ping_url', array( 907 'action' => 'calendar/push-freebusy', 908 // _folder_id may be set by invitations calendar 909 'source' => $master['_folder_id'] ?: $storage->id, 910 )); 911 912 return $success ? $ret : false; 913 } 914 915 /** 916 * Restore a single deleted event 917 * 918 * @param array Hash array with event properties: 919 * id: Event identifier 920 * calendar: Event calendar 921 * 922 * @return boolean True on success, False on error 923 */ 924 public function restore_event($event) 925 { 926 if ($storage = $this->get_calendar($event['calendar'])) { 927 if (!empty($_SESSION['calendar_restore_event_data'])) 928 $success = $storage->update_event($event = $_SESSION['calendar_restore_event_data']); 929 else 930 $success = $storage->restore_event($event); 931 932 if ($success && $this->freebusy_trigger) 933 $this->rc->output->command('plugin.ping_url', array( 934 'action' => 'calendar/push-freebusy', 935 // _folder_id may be set by invitations calendar 936 'source' => $event['_folder_id'] ?: $storage->id, 937 )); 938 939 return $success; 940 } 941 942 return false; 943 } 944 945 /** 946 * Wrapper to update an event object depending on the given savemode 947 */ 948 private function update_event($event) 949 { 950 if (!($storage = $this->get_calendar($event['calendar']))) 951 return false; 952 953 // move event to another folder/calendar 954 if ($event['_fromcalendar'] && $event['_fromcalendar'] != $event['calendar']) { 955 if (!($fromcalendar = $this->get_calendar($event['_fromcalendar']))) 956 return false; 957 958 $old = $fromcalendar->get_event($event['id']); 959 960 if ($event['_savemode'] != 'new') { 961 if (!$fromcalendar->storage->move($old['uid'], $storage->storage)) { 962 return false; 963 } 964 965 $fromcalendar = $storage; 966 } 967 } 968 else 969 $fromcalendar = $storage; 970 971 $success = false; 972 $savemode = 'all'; 973 $attachments = array(); 974 $old = $master = $storage->get_event($event['id']); 975 976 if (!$old || !$old['start']) { 977 rcube::raise_error(array( 978 'code' => 600, 'type' => 'php', 979 'file' => __FILE__, 'line' => __LINE__, 980 'message' => "Failed to load event object to update: id=" . $event['id']), 981 true, false); 982 return false; 983 } 984 985 // modify a recurring event, check submitted savemode to do the right things 986 if ($old['recurrence'] || $old['recurrence_id'] || $old['isexception']) { 987 $master = $storage->get_event($old['uid']); 988 $savemode = $event['_savemode'] ?: ($old['recurrence_id'] || $old['isexception'] ? 'current' : 'all'); 989 990 // this-and-future on the first instance equals to 'all' 991 if ($savemode == 'future' && $master['start'] && $old['_instance'] == libcalendaring::recurrence_instance_identifier($master)) 992 $savemode = 'all'; 993 // force 'current' mode for single occurrences stored as exception 994 else if (!$old['recurrence'] && !$old['recurrence_id'] && $old['isexception']) 995 $savemode = 'current'; 996 997 // Stick to the master timezone for all occurrences (Bifrost#T104637) 998 $master_tz = $master['start']->getTimezone(); 999 $event_tz = $event['start']->getTimezone(); 1000 1001 if ($master_tz->getName() != $event_tz->getName()) { 1002 $event['start']->setTimezone($master_tz); 1003 $event['end']->setTimezone($master_tz); 1004 } 1005 } 1006 1007 // check if update affects scheduling and update attendee status accordingly 1008 $reschedule = $this->check_scheduling($event, $old, true); 1009 1010 // keep saved exceptions (not submitted by the client) 1011 if ($old['recurrence']['EXDATE'] && !isset($event['recurrence']['EXDATE'])) 1012 $event['recurrence']['EXDATE'] = $old['recurrence']['EXDATE']; 1013 if (isset($event['recurrence']['EXCEPTIONS'])) 1014 $with_exceptions = true; // exceptions already provided (e.g. from iCal import) 1015 else if ($old['recurrence']['EXCEPTIONS']) 1016 $event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS']; 1017 else if ($old['exceptions']) 1018 $event['exceptions'] = $old['exceptions']; 1019 1020 // remove some internal properties which should not be saved 1021 unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_owner'], 1022 $event['_notify'], $event['_method'], $event['_sender'], $event['_sender_utf'], $event['_size']); 1023 1024 switch ($savemode) { 1025 case 'new': 1026 // save submitted data as new (non-recurring) event 1027 $event['recurrence'] = array(); 1028 $event['_copyfrom'] = $master['_msguid']; 1029 $event['_mailbox'] = $master['_mailbox']; 1030 $event['uid'] = $this->cal->generate_uid(); 1031 unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); 1032 1033 // copy attachment metadata to new event 1034 $event = self::from_rcube_event($event, $master); 1035 1036 self::clear_attandee_noreply($event); 1037 if ($success = $storage->insert_event($event)) 1038 $success = $event['uid']; 1039 break; 1040 1041 case 'future': 1042 // create a new recurring event 1043 $event['_copyfrom'] = $master['_msguid']; 1044 $event['_mailbox'] = $master['_mailbox']; 1045 $event['uid'] = $this->cal->generate_uid(); 1046 unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); 1047 1048 // copy attachment metadata to new event 1049 $event = self::from_rcube_event($event, $master); 1050 1051 // remove recurrence exceptions on re-scheduling 1052 if ($reschedule) { 1053 unset($event['recurrence']['EXCEPTIONS'], $event['exceptions'], $master['recurrence']['EXDATE']); 1054 } 1055 else if (is_array($event['recurrence']['EXCEPTIONS'])) { 1056 // only keep relevant exceptions 1057 $event['recurrence']['EXCEPTIONS'] = array_filter($event['recurrence']['EXCEPTIONS'], function($exception) use ($event) { 1058 return $exception['start'] > $event['start']; 1059 }); 1060 if (is_array($event['recurrence']['EXDATE'])) { 1061 $event['recurrence']['EXDATE'] = array_filter($event['recurrence']['EXDATE'], function($exdate) use ($event) { 1062 return $exdate > $event['start']; 1063 }); 1064 } 1065 // set link to top-level exceptions 1066 $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; 1067 } 1068 1069 // compute remaining occurrences 1070 if ($event['recurrence']['COUNT']) { 1071 if (!$old['_count']) 1072 $old['_count'] = $this->get_recurrence_count($master, $old['start']); 1073 $event['recurrence']['COUNT'] -= intval($old['_count']); 1074 } 1075 1076 // remove fixed weekday when date changed 1077 if ($old['start']->format('Y-m-d') != $event['start']->format('Y-m-d')) { 1078 if (strlen($event['recurrence']['BYDAY']) == 2) 1079 unset($event['recurrence']['BYDAY']); 1080 if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) 1081 unset($event['recurrence']['BYMONTH']); 1082 } 1083 1084 // set until-date on master event 1085 $master['recurrence']['UNTIL'] = clone $old['start']; 1086 $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); 1087 unset($master['recurrence']['COUNT']); 1088 1089 // remove all exceptions after $event['start'] 1090 if (is_array($master['recurrence']['EXCEPTIONS'])) { 1091 $master['recurrence']['EXCEPTIONS'] = array_filter($master['recurrence']['EXCEPTIONS'], function($exception) use ($event) { 1092 return $exception['start'] < $event['start']; 1093 }); 1094 // set link to top-level exceptions 1095 $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; 1096 } 1097 if (is_array($master['recurrence']['EXDATE'])) { 1098 $master['recurrence']['EXDATE'] = array_filter($master['recurrence']['EXDATE'], function($exdate) use ($event) { 1099 return $exdate < $event['start']; 1100 }); 1101 } 1102 1103 // save new event 1104 if ($success = $storage->insert_event($event)) { 1105 $success = $event['uid']; 1106 1107 // update master event (no rescheduling!) 1108 self::clear_attandee_noreply($master); 1109 $storage->update_event($master); 1110 } 1111 break; 1112 1113 case 'current': 1114 // recurring instances shall not store recurrence rules and attachments 1115 $event['recurrence'] = array(); 1116 $event['thisandfuture'] = $savemode == 'future'; 1117 unset($event['attachments'], $event['id']); 1118 1119 // increment sequence of this instance if scheduling is affected 1120 if ($reschedule) { 1121 $event['sequence'] = max($old['sequence'], $master['sequence']) + 1; 1122 } 1123 else if (!isset($event['sequence'])) { 1124 $event['sequence'] = $old['sequence'] ?: $master['sequence']; 1125 } 1126 1127 // save properties to a recurrence exception instance 1128 if ($old['_instance'] && is_array($master['recurrence']['EXCEPTIONS'])) { 1129 if ($this->update_recurrence_exceptions($master, $event, $old, $savemode)) { 1130 $success = $storage->update_event($master, $old['id']); 1131 break; 1132 } 1133 } 1134 1135 $add_exception = true; 1136 1137 // adjust matching RDATE entry if dates changed 1138 if (is_array($master['recurrence']['RDATE']) && ($old_date = $old['start']->format('Ymd')) != $event['start']->format('Ymd')) { 1139 foreach ($master['recurrence']['RDATE'] as $j => $rdate) { 1140 if ($rdate->format('Ymd') == $old_date) { 1141 $master['recurrence']['RDATE'][$j] = $event['start']; 1142 sort($master['recurrence']['RDATE']); 1143 $add_exception = false; 1144 break; 1145 } 1146 } 1147 } 1148 1149 // save as new exception to master event 1150 if ($add_exception) { 1151 self::add_exception($master, $event, $old); 1152 } 1153 1154 $success = $storage->update_event($master); 1155 break; 1156 1157 default: // 'all' is default 1158 $event['id'] = $master['uid']; 1159 $event['uid'] = $master['uid']; 1160 1161 // use start date from master but try to be smart on time or duration changes 1162 $old_start_date = $old['start']->format('Y-m-d'); 1163 $old_start_time = $old['allday'] ? '' : $old['start']->format('H:i'); 1164 $old_duration = self::event_duration($old['start'], $old['end'], $old['allday']); 1165 1166 $new_start_date = $event['start']->format('Y-m-d'); 1167 $new_start_time = $event['allday'] ? '' : $event['start']->format('H:i'); 1168 $new_duration = self::event_duration($event['start'], $event['end'], $event['allday']); 1169 1170 $diff = $old_start_date != $new_start_date || $old_start_time != $new_start_time || $old_duration != $new_duration; 1171 $date_shift = $old['start']->diff($event['start']); 1172 1173 // shifted or resized 1174 if ($diff && ($old_start_date == $new_start_date || $old_duration == $new_duration)) { 1175 $event['start'] = $master['start']->add($date_shift); 1176 $event['end'] = clone $event['start']; 1177 $event['end']->add(new DateInterval($new_duration)); 1178 1179 // remove fixed weekday, will be re-set to the new weekday in kolab_calendar::update_event() 1180 if ($old_start_date != $new_start_date && $event['recurrence']) { 1181 if (strlen($event['recurrence']['BYDAY']) == 2) 1182 unset($event['recurrence']['BYDAY']); 1183 if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) 1184 unset($event['recurrence']['BYMONTH']); 1185 } 1186 } 1187 // dates did not change, use the ones from master 1188 else if ($new_start_date . $new_start_time == $old_start_date . $old_start_time) { 1189 $event['start'] = $master['start']; 1190 $event['end'] = $master['end']; 1191 } 1192 1193 // when saving an instance in 'all' mode, copy recurrence exceptions over 1194 if ($old['recurrence_id']) { 1195 $event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS']; 1196 $event['recurrence']['EXDATE'] = $master['recurrence']['EXDATE']; 1197 } 1198 else if ($master['_instance']) { 1199 $event['_instance'] = $master['_instance']; 1200 $event['recurrence_date'] = $master['recurrence_date']; 1201 } 1202 1203 // TODO: forward changes to exceptions (which do not yet have differing values stored) 1204 if (is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) { 1205 // determine added and removed attendees 1206 $old_attendees = $current_attendees = $added_attendees = array(); 1207 foreach ((array)$old['attendees'] as $attendee) { 1208 $old_attendees[] = $attendee['email']; 1209 } 1210 foreach ((array)$event['attendees'] as $attendee) { 1211 $current_attendees[] = $attendee['email']; 1212 if (!in_array($attendee['email'], $old_attendees)) { 1213 $added_attendees[] = $attendee; 1214 } 1215 } 1216 $removed_attendees = array_diff($old_attendees, $current_attendees); 1217 1218 foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { 1219 calendar::merge_attendee_data($event['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); 1220 } 1221 1222 // adjust recurrence-id when start changed and therefore the entire recurrence chain changes 1223 if ($old_start_date != $new_start_date || $old_start_time != $new_start_time) { 1224 $recurrence_id_format = libcalendaring::recurrence_id_format($event); 1225 foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { 1226 $recurrence_id = is_a($exception['recurrence_date'], 'DateTime') ? $exception['recurrence_date'] : 1227 rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone()); 1228 if (is_a($recurrence_id, 'DateTime')) { 1229 $recurrence_id->add($date_shift); 1230 $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id; 1231 $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format); 1232 } 1233 } 1234 } 1235 1236 // set link to top-level exceptions 1237 $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; 1238 } 1239 1240 // unset _dateonly flags in (cached) date objects 1241 unset($event['start']->_dateonly, $event['end']->_dateonly); 1242 1243 $success = $storage->update_event($event) ? $event['id'] : false; // return master UID 1244 break; 1245 } 1246 1247 if ($success && $this->freebusy_trigger) 1248 $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); 1249 1250 return $success; 1251 } 1252 1253 /** 1254 * Calculate event duration, returns string in DateInterval format 1255 */ 1256 protected static function event_duration($start, $end, $allday = false) 1257 { 1258 if ($allday) { 1259 $diff = $start->diff($end); 1260 return 'P' . $diff->days . 'D'; 1261 } 1262 1263 return 'PT' . ($end->format('U') - $start->format('U')) . 'S'; 1264 } 1265 1266 /** 1267 * Determine whether the current change affects scheduling and reset attendee status accordingly 1268 */ 1269 public function check_scheduling(&$event, $old, $update = true) 1270 { 1271 // skip this check when importing iCal/iTip events 1272 if (isset($event['sequence']) || !empty($event['_method'])) { 1273 return false; 1274 } 1275 1276 // iterate through the list of properties considered 'significant' for scheduling 1277 $kolab_event = $old['_formatobj'] ?: new kolab_format_event(); 1278 $reschedule = $kolab_event->check_rescheduling($event, $old); 1279 1280 // reset all attendee status to needs-action (#4360) 1281 if ($update && $reschedule && is_array($event['attendees'])) { 1282 $is_organizer = false; 1283 $emails = $this->cal->get_user_emails(); 1284 $attendees = $event['attendees']; 1285 foreach ($attendees as $i => $attendee) { 1286 if ($attendee['role'] == 'ORGANIZER' && $attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { 1287 $is_organizer = true; 1288 } 1289 else if ($attendee['role'] != 'ORGANIZER' && $attendee['role'] != 'NON-PARTICIPANT' && $attendee['status'] != 'DELEGATED') { 1290 $attendees[$i]['status'] = 'NEEDS-ACTION'; 1291 $attendees[$i]['rsvp'] = true; 1292 } 1293 } 1294 1295 // update attendees only if I'm the organizer 1296 if ($is_organizer || ($event['organizer'] && in_array(strtolower($event['organizer']['email']), $emails))) { 1297 $event['attendees'] = $attendees; 1298 } 1299 } 1300 1301 return $reschedule; 1302 } 1303 1304 /** 1305 * Apply the given changes to already existing exceptions 1306 */ 1307 protected function update_recurrence_exceptions(&$master, $event, $old, $savemode) 1308 { 1309 $saved = false; 1310 $existing = null; 1311 1312 // determine added and removed attendees 1313 $added_attendees = $removed_attendees = array(); 1314 if ($savemode == 'future') { 1315 $old_attendees = $current_attendees = array(); 1316 foreach ((array)$old['attendees'] as $attendee) { 1317 $old_attendees[] = $attendee['email']; 1318 } 1319 foreach ((array)$event['attendees'] as $attendee) { 1320 $current_attendees[] = $attendee['email']; 1321 if (!in_array($attendee['email'], $old_attendees)) { 1322 $added_attendees[] = $attendee; 1323 } 1324 } 1325 $removed_attendees = array_diff($old_attendees, $current_attendees); 1326 } 1327 1328 foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { 1329 // update a specific instance 1330 if ($exception['_instance'] == $old['_instance']) { 1331 $existing = $i; 1332 1333 // check savemode against existing exception mode. 1334 // if matches, we can update this existing exception 1335 if ((bool)$exception['thisandfuture'] === ($savemode == 'future')) { 1336 $event['_instance'] = $old['_instance']; 1337 $event['thisandfuture'] = $old['thisandfuture']; 1338 $event['recurrence_date'] = $old['recurrence_date']; 1339 $master['recurrence']['EXCEPTIONS'][$i] = $event; 1340 $saved = true; 1341 } 1342 } 1343 // merge the new event properties onto future exceptions 1344 if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { 1345 unset($event['thisandfuture']); 1346 self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, array('attendees')); 1347 1348 if (!empty($added_attendees) || !empty($removed_attendees)) { 1349 calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); 1350 } 1351 } 1352 } 1353/* 1354 // we could not update the existing exception due to savemode mismatch... 1355 if (!$saved && $existing !== null && $master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture']) { 1356 // ... try to move the existing this-and-future exception to the next occurrence 1357 foreach ($this->get_recurring_events($master, $existing['start']) as $candidate) { 1358 // our old this-and-future exception is obsolete 1359 if ($candidate['thisandfuture']) { 1360 unset($master['recurrence']['EXCEPTIONS'][$existing]); 1361 $saved = true; 1362 break; 1363 } 1364 // this occurrence doesn't yet have an exception 1365 else if (!$candidate['isexception']) { 1366 $event['_instance'] = $candidate['_instance']; 1367 $event['recurrence_date'] = $candidate['recurrence_date']; 1368 $master['recurrence']['EXCEPTIONS'][$i] = $event; 1369 $saved = true; 1370 break; 1371 } 1372 } 1373 } 1374*/ 1375 1376 // set link to top-level exceptions 1377 $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; 1378 1379 // returning false here will add a new exception 1380 return $saved; 1381 } 1382 1383 /** 1384 * Add or update the given event as an exception to $master 1385 */ 1386 public static function add_exception(&$master, $event, $old = null) 1387 { 1388 if ($old) { 1389 $event['_instance'] = $old['_instance']; 1390 if (!$event['recurrence_date']) 1391 $event['recurrence_date'] = $old['recurrence_date'] ?: $old['start']; 1392 } 1393 else if (!$event['recurrence_date']) { 1394 $event['recurrence_date'] = $event['start']; 1395 } 1396 1397 if (!$event['_instance'] && is_a($event['recurrence_date'], 'DateTime')) { 1398 $event['_instance'] = libcalendaring::recurrence_instance_identifier($event, $master['allday']); 1399 } 1400 1401 if (!is_array($master['exceptions']) && is_array($master['recurrence']['EXCEPTIONS'])) { 1402 $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; 1403 } 1404 1405 $existing = false; 1406 foreach ((array)$master['exceptions'] as $i => $exception) { 1407 if ($exception['_instance'] == $event['_instance']) { 1408 $master['exceptions'][$i] = $event; 1409 $existing = true; 1410 } 1411 } 1412 1413 if (!$existing) { 1414 $master['exceptions'][] = $event; 1415 } 1416 1417 return true; 1418 } 1419 1420 /** 1421 * Remove the noreply flags from attendees 1422 */ 1423 public static function clear_attandee_noreply(&$event) 1424 { 1425 foreach ((array)$event['attendees'] as $i => $attendee) { 1426 unset($event['attendees'][$i]['noreply']); 1427 } 1428 } 1429 1430 /** 1431 * Merge certain properties from the overlay event to the base event object 1432 * 1433 * @param array The event object to be altered 1434 * @param array The overlay event object to be merged over $event 1435 * @param array List of properties not allowed to be overwritten 1436 */ 1437 public static function merge_exception_data(&$event, $overlay, $blacklist = null) 1438 { 1439 $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments'); 1440 1441 if (is_array($blacklist)) 1442 $forbidden = array_merge($forbidden, $blacklist); 1443 1444 foreach ($overlay as $prop => $value) { 1445 if ($prop == 'start' || $prop == 'end') { 1446 // handled by merge_exception_dates() below 1447 } 1448 else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) { 1449 $event[$prop] = $value; 1450 } 1451 else if ($prop[0] != '_' && !in_array($prop, $forbidden)) 1452 $event[$prop] = $value; 1453 } 1454 1455 self::merge_exception_dates($event, $overlay); 1456 } 1457 1458 /** 1459 * Merge start/end date from the overlay event to the base event object 1460 * 1461 * @param array The event object to be altered 1462 * @param array The overlay event object to be merged over $event 1463 */ 1464 public static function merge_exception_dates(&$event, $overlay) 1465 { 1466 // compute date offset from the exception 1467 if ($overlay['start'] instanceof DateTime && $overlay['recurrence_date'] instanceof DateTime) { 1468 $date_offset = $overlay['recurrence_date']->diff($overlay['start']); 1469 } 1470 1471 foreach (array('start', 'end') as $prop) { 1472 $value = $overlay[$prop]; 1473 if (is_object($event[$prop]) && $event[$prop] instanceof DateTime) { 1474 // set date value if overlay is an exception of the current instance 1475 if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) { 1476 $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j'))); 1477 } 1478 // apply date offset 1479 else if ($date_offset) { 1480 $event[$prop]->add($date_offset); 1481 } 1482 // adjust time of the recurring event instance 1483 $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); 1484 } 1485 } 1486 } 1487 1488 /** 1489 * Get events from source. 1490 * 1491 * @param integer Event's new start (unix timestamp) 1492 * @param integer Event's new end (unix timestamp) 1493 * @param string Search query (optional) 1494 * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) 1495 * @param boolean Include virtual events (optional) 1496 * @param integer Only list events modified since this time (unix timestamp) 1497 * @return array A list of event records 1498 */ 1499 public function load_events($start, $end, $search = null, $calendars = null, $virtual = 1, $modifiedsince = null) 1500 { 1501 if ($calendars && is_string($calendars)) 1502 $calendars = explode(',', $calendars); 1503 else if (!$calendars) { 1504 $this->_read_calendars(); 1505 $calendars = array_keys($this->calendars); 1506 } 1507 1508 $query = array(); 1509 if ($modifiedsince) 1510 $query[] = array('changed', '>=', $modifiedsince); 1511 1512 $events = $categories = array(); 1513 foreach ($calendars as $cid) { 1514 if ($storage = $this->get_calendar($cid)) { 1515 $events = array_merge($events, $storage->list_events($start, $end, $search, $virtual, $query)); 1516 $categories += $storage->categories; 1517 } 1518 } 1519 1520 // add events from the address books birthday calendar 1521 if (in_array(self::BIRTHDAY_CALENDAR_ID, $calendars)) { 1522 $events = array_merge($events, $this->load_birthday_events($start, $end, $search, $modifiedsince)); 1523 } 1524 1525 // add new categories to user prefs 1526 $old_categories = $this->rc->config->get('calendar_categories', $this->default_categories); 1527 if ($newcats = array_udiff(array_keys($categories), array_keys($old_categories), function($a, $b){ return strcasecmp($a, $b); })) { 1528 foreach ($newcats as $category) 1529 $old_categories[$category] = ''; // no color set yet 1530 $this->rc->user->save_prefs(array('calendar_categories' => $old_categories)); 1531 } 1532 1533 array_walk($events, 'kolab_driver::to_rcube_event'); 1534 return $events; 1535 } 1536 1537 /** 1538 * Get number of events in the given calendar 1539 * 1540 * @param mixed List of calendar IDs to count events (either as array or comma-separated string) 1541 * @param integer Date range start (unix timestamp) 1542 * @param integer Date range end (unix timestamp) 1543 * @return array Hash array with counts grouped by calendar ID 1544 */ 1545 public function count_events($calendars, $start, $end = null) 1546 { 1547 $counts = array(); 1548 1549 if ($calendars && is_string($calendars)) 1550 $calendars = explode(',', $calendars); 1551 else if (!$calendars) { 1552 $this->_read_calendars(); 1553 $calendars = array_keys($this->calendars); 1554 } 1555 1556 foreach ($calendars as $cid) { 1557 if ($storage = $this->get_calendar($cid)) { 1558 $counts[$cid] = $storage->count_events($start, $end); 1559 } 1560 } 1561 1562 return $counts; 1563 } 1564 1565 /** 1566 * Get a list of pending alarms to be displayed to the user 1567 * 1568 * @see calendar_driver::pending_alarms() 1569 */ 1570 public function pending_alarms($time, $calendars = null) 1571 { 1572 $interval = 300; 1573 $time -= $time % 60; 1574 1575 $slot = $time; 1576 $slot -= $slot % $interval; 1577 1578 $last = $time - max(60, $this->rc->config->get('refresh_interval', 0)); 1579 $last -= $last % $interval; 1580 1581 // only check for alerts once in 5 minutes 1582 if ($last == $slot) 1583 return array(); 1584 1585 if ($calendars && is_string($calendars)) 1586 $calendars = explode(',', $calendars); 1587 1588 $time = $slot + $interval; 1589 1590 $candidates = array(); 1591 $query = array(array('tags', '=', 'x-has-alarms')); 1592 1593 $this->_read_calendars(); 1594 1595 foreach ($this->calendars as $cid => $calendar) { 1596 // skip calendars with alarms disabled 1597 if (!$calendar->alarms || ($calendars && !in_array($cid, $calendars))) 1598 continue; 1599 1600 foreach ($calendar->list_events($time, $time + 86400 * 365, null, 1, $query) as $e) { 1601 // add to list if alarm is set 1602 $alarm = libcalendaring::get_next_alarm($e); 1603 if ($alarm && $alarm['time'] && $alarm['time'] >= $last && in_array($alarm['action'], $this->alarm_types)) { 1604 $id = $alarm['id']; // use alarm-id as primary identifier 1605 $candidates[$id] = array( 1606 'id' => $id, 1607 'title' => $e['title'], 1608 'location' => $e['location'], 1609 'start' => $e['start'], 1610 'end' => $e['end'], 1611 'notifyat' => $alarm['time'], 1612 'action' => $alarm['action'], 1613 ); 1614 } 1615 } 1616 } 1617 1618 // get alarm information stored in local database 1619 if (!empty($candidates)) { 1620 $alarm_ids = array_map(array($this->rc->db, 'quote'), array_keys($candidates)); 1621 $result = $this->rc->db->query("SELECT *" 1622 . " FROM " . $this->rc->db->table_name('kolab_alarms', true) 1623 . " WHERE `alarm_id` IN (" . join(',', $alarm_ids) . ")" 1624 . " AND `user_id` = ?", 1625 $this->rc->user->ID 1626 ); 1627 1628 while ($result && ($e = $this->rc->db->fetch_assoc($result))) { 1629 $dbdata[$e['alarm_id']] = $e; 1630 } 1631 } 1632 1633 $alarms = array(); 1634 foreach ($candidates as $id => $alarm) { 1635 // skip dismissed alarms 1636 if ($dbdata[$id]['dismissed']) 1637 continue; 1638 1639 // snooze function may have shifted alarm time 1640 $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $alarm['notifyat']; 1641 if ($notifyat <= $time) 1642 $alarms[] = $alarm; 1643 } 1644 1645 return $alarms; 1646 } 1647 1648 /** 1649 * Feedback after showing/sending an alarm notification 1650 * 1651 * @see calendar_driver::dismiss_alarm() 1652 */ 1653 public function dismiss_alarm($alarm_id, $snooze = 0) 1654 { 1655 $alarms_table = $this->rc->db->table_name('kolab_alarms', true); 1656 // delete old alarm entry 1657 $this->rc->db->query("DELETE FROM $alarms_table" 1658 . " WHERE `alarm_id` = ? AND `user_id` = ?", 1659 $alarm_id, 1660 $this->rc->user->ID 1661 ); 1662 1663 // set new notifyat time or unset if not snoozed 1664 $notifyat = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; 1665 1666 $query = $this->rc->db->query("INSERT INTO $alarms_table" 1667 . " (`alarm_id`, `user_id`, `dismissed`, `notifyat`)" 1668 . " VALUES (?, ?, ?, ?)", 1669 $alarm_id, 1670 $this->rc->user->ID, 1671 $snooze > 0 ? 0 : 1, 1672 $notifyat 1673 ); 1674 1675 return $this->rc->db->affected_rows($query); 1676 } 1677 1678 /** 1679 * List attachments from the given event 1680 */ 1681 public function list_attachments($event) 1682 { 1683 if (!($storage = $this->get_calendar($event['calendar']))) 1684 return false; 1685 1686 $event = $storage->get_event($event['id']); 1687 1688 return $event['attachments']; 1689 } 1690 1691 /** 1692 * Get attachment properties 1693 */ 1694 public function get_attachment($id, $event) 1695 { 1696 if (!($storage = $this->get_calendar($event['calendar']))) 1697 return false; 1698 1699 // get old revision of event 1700 if ($event['rev']) { 1701 $event = $this->get_event_revison($event, $event['rev'], true); 1702 } 1703 else { 1704 $event = $storage->get_event($event['id']); 1705 } 1706 1707 if ($event) { 1708 $attachments = isset($event['_attachments']) ? $event['_attachments'] : $event['attachments']; 1709 foreach ((array) $attachments as $att) { 1710 if ($att['id'] == $id) { 1711 return $att; 1712 } 1713 } 1714 } 1715 } 1716 1717 /** 1718 * Get attachment body 1719 * @see calendar_driver::get_attachment_body() 1720 */ 1721 public function get_attachment_body($id, $event) 1722 { 1723 if (!($cal = $this->get_calendar($event['calendar']))) 1724 return false; 1725 1726 // get old revision of event 1727 if ($event['rev']) { 1728 if (empty($this->bonnie_api)) { 1729 return false; 1730 } 1731 1732 $cid = substr($id, 4); 1733 1734 // call Bonnie API and get the raw mime message 1735 list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); 1736 if ($msg_raw = $this->bonnie_api->rawdata('event', $uid, $event['rev'], $mailbox, $msguid)) { 1737 // parse the message and find the part with the matching content-id 1738 $message = rcube_mime::parse_message($msg_raw); 1739 foreach ((array)$message->parts as $part) { 1740 if ($part->headers['content-id'] && trim($part->headers['content-id'], '<>') == $cid) { 1741 return $part->body; 1742 } 1743 } 1744 } 1745 1746 return false; 1747 } 1748 1749 return $cal->get_attachment_body($id, $event); 1750 } 1751 1752 /** 1753 * Build a struct representing the given message reference 1754 * 1755 * @see calendar_driver::get_message_reference() 1756 */ 1757 public function get_message_reference($uri_or_headers, $folder = null) 1758 { 1759 if (is_object($uri_or_headers)) { 1760 $uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder); 1761 } 1762 1763 if (is_string($uri_or_headers)) { 1764 return kolab_storage_config::get_message_reference($uri_or_headers, 'event'); 1765 } 1766 1767 return false; 1768 } 1769 1770 /** 1771 * List availabale categories 1772 * The default implementation reads them from config/user prefs 1773 */ 1774 public function list_categories() 1775 { 1776 // FIXME: complete list with categories saved in config objects (KEP:12) 1777 return $this->rc->config->get('calendar_categories', $this->default_categories); 1778 } 1779 1780 /** 1781 * Create instances of a recurring event 1782 * 1783 * @param array Hash array with event properties 1784 * @param object DateTime Start date of the recurrence window 1785 * @param object DateTime End date of the recurrence window 1786 * @return array List of recurring event instances 1787 */ 1788 public function get_recurring_events($event, $start, $end = null) 1789 { 1790 // load the given event data into a libkolabxml container 1791 if (!$event['_formatobj']) { 1792 $event_xml = new kolab_format_event(); 1793 $event_xml->set($event); 1794 $event['_formatobj'] = $event_xml; 1795 } 1796 1797 $this->_read_calendars(); 1798 $storage = reset($this->calendars); 1799 return $storage->get_recurring_events($event, $start, $end); 1800 } 1801 1802 /** 1803 * 1804 */ 1805 private function get_recurrence_count($event, $dtstart) 1806 { 1807 // load the given event data into a libkolabxml container 1808 if (!$event['_formatobj']) { 1809 $event_xml = new kolab_format_event(); 1810 $event_xml->set($event); 1811 $event['_formatobj'] = $event_xml; 1812 } 1813 1814 // use libkolab to compute recurring events 1815 $recurrence = new kolab_date_recurrence($event['_formatobj']); 1816 1817 $count = 0; 1818 while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { 1819 $count++; 1820 } 1821 1822 return $count; 1823 } 1824 1825 /** 1826 * Fetch free/busy information from a person within the given range 1827 */ 1828 public function get_freebusy_list($email, $start, $end) 1829 { 1830 if (empty($email)/* || $end < time()*/) 1831 return false; 1832 1833 // map vcalendar fbtypes to internal values 1834 $fbtypemap = array( 1835 'FREE' => calendar::FREEBUSY_FREE, 1836 'BUSY-TENTATIVE' => calendar::FREEBUSY_TENTATIVE, 1837 'X-OUT-OF-OFFICE' => calendar::FREEBUSY_OOF, 1838 'OOF' => calendar::FREEBUSY_OOF); 1839 1840 // ask kolab server first 1841 try { 1842 $request_config = array( 1843 'store_body' => true, 1844 'follow_redirects' => true, 1845 ); 1846 $request = libkolab::http_request(kolab_storage::get_freebusy_url($email), 'GET', $request_config); 1847 $response = $request->send(); 1848 1849 // authentication required 1850 if ($response->getStatus() == 401) { 1851 $request->setAuth($this->rc->user->get_username(), $this->rc->decrypt($_SESSION['password'])); 1852 $response = $request->send(); 1853 } 1854 1855 if ($response->getStatus() == 200) 1856 $fbdata = $response->getBody(); 1857 1858 unset($request, $response); 1859 } 1860 catch (Exception $e) { 1861 PEAR::raiseError("Error fetching free/busy information: " . $e->getMessage()); 1862 } 1863 1864 // get free-busy url from contacts 1865 if (!$fbdata) { 1866 $fburl = null; 1867 foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $book) { 1868 $abook = $this->rc->get_address_book($book); 1869 1870 if ($result = $abook->search(array('email'), $email, true, true, true/*, 'freebusyurl'*/)) { 1871 while ($contact = $result->iterate()) { 1872 if ($fburl = $contact['freebusyurl']) { 1873 $fbdata = @file_get_contents($fburl); 1874 break; 1875 } 1876 } 1877 } 1878 1879 if ($fbdata) 1880 break; 1881 } 1882 } 1883 1884 // parse free-busy information using Horde classes 1885 if ($fbdata) { 1886 $ical = $this->cal->get_ical(); 1887 $ical->import($fbdata); 1888 if ($fb = $ical->freebusy) { 1889 $result = array(); 1890 foreach ($fb['periods'] as $tuple) { 1891 list($from, $to, $type) = $tuple; 1892 $result[] = array($from->format('U'), $to->format('U'), isset($fbtypemap[$type]) ? $fbtypemap[$type] : calendar::FREEBUSY_BUSY); 1893 } 1894 1895 // we take 'dummy' free-busy lists as "unknown" 1896 if (empty($result) && !empty($fb['comment']) && stripos($fb['comment'], 'dummy')) 1897 return false; 1898 1899 // set period from $start till the begin of the free-busy information as 'unknown' 1900 if ($fb['start'] && ($fbstart = $fb['start']->format('U')) && $start < $fbstart) { 1901 array_unshift($result, array($start, $fbstart, calendar::FREEBUSY_UNKNOWN)); 1902 } 1903 // pad period till $end with status 'unknown' 1904 if ($fb['end'] && ($fbend = $fb['end']->format('U')) && $fbend < $end) { 1905 $result[] = array($fbend, $end, calendar::FREEBUSY_UNKNOWN); 1906 } 1907 1908 return $result; 1909 } 1910 } 1911 1912 return false; 1913 } 1914 1915 /** 1916 * Handler to push folder triggers when sent from client. 1917 * Used to push free-busy changes asynchronously after updating an event 1918 */ 1919 public function push_freebusy() 1920 { 1921 // make shure triggering completes 1922 set_time_limit(0); 1923 ignore_user_abort(true); 1924 1925 $cal = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); 1926 if (!($cal = $this->get_calendar($cal))) 1927 return false; 1928 1929 // trigger updates on folder 1930 $trigger = $cal->storage->trigger(); 1931 if (is_object($trigger) && is_a($trigger, 'PEAR_Error')) { 1932 rcube::raise_error(array( 1933 'code' => 900, 'type' => 'php', 1934 'file' => __FILE__, 'line' => __LINE__, 1935 'message' => "Failed triggering folder. Error was " . $trigger->getMessage()), 1936 true, false); 1937 } 1938 1939 exit; 1940 } 1941 1942 1943 /** 1944 * Convert from driver format to external caledar app data 1945 */ 1946 public static function to_rcube_event(&$record) 1947 { 1948 if (!is_array($record)) 1949 return $record; 1950 1951 $record['id'] = $record['uid']; 1952 1953 if ($record['_instance']) { 1954 $record['id'] .= '-' . $record['_instance']; 1955 1956 if (!$record['recurrence_id'] && !empty($record['recurrence'])) 1957 $record['recurrence_id'] = $record['uid']; 1958 } 1959 1960 // all-day events go from 12:00 - 13:00 1961 if (is_a($record['start'], 'DateTime') && $record['end'] <= $record['start'] && $record['allday']) { 1962 $record['end'] = clone $record['start']; 1963 $record['end']->add(new DateInterval('PT1H')); 1964 } 1965 1966 // translate internal '_attachments' to external 'attachments' list 1967 if (!empty($record['_attachments'])) { 1968 foreach ($record['_attachments'] as $key => $attachment) { 1969 if ($attachment !== false) { 1970 if (!$attachment['name']) 1971 $attachment['name'] = $key; 1972 1973 unset($attachment['path'], $attachment['content']); 1974 $attachments[] = $attachment; 1975 } 1976 } 1977 1978 $record['attachments'] = $attachments; 1979 } 1980 1981 if (!empty($record['attendees'])) { 1982 foreach ((array)$record['attendees'] as $i => $attendee) { 1983 if (is_array($attendee['delegated-from'])) { 1984 $record['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); 1985 } 1986 if (is_array($attendee['delegated-to'])) { 1987 $record['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); 1988 } 1989 } 1990 } 1991 1992 // Roundcube only supports one category assignment 1993 if (is_array($record['categories'])) 1994 $record['categories'] = $record['categories'][0]; 1995 1996 // the cancelled flag transltes into status=CANCELLED 1997 if ($record['cancelled']) 1998 $record['status'] = 'CANCELLED'; 1999 2000 // The web client only supports DISPLAY type of alarms 2001 if (!empty($record['alarms'])) 2002 $record['alarms'] = preg_replace('/:[A-Z]+$/', ':DISPLAY', $record['alarms']); 2003 2004 // remove empty recurrence array 2005 if (empty($record['recurrence'])) 2006 unset($record['recurrence']); 2007 2008 // clean up exception data 2009 if (is_array($record['recurrence']['EXCEPTIONS'])) { 2010 array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) { 2011 unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments']); 2012 }); 2013 } 2014 2015 unset($record['_mailbox'], $record['_msguid'], $record['_type'], $record['_size'], 2016 $record['_formatobj'], $record['_attachments'], $record['exceptions'], $record['x-custom']); 2017 2018 return $record; 2019 } 2020 2021 /** 2022 * 2023 */ 2024 public static function from_rcube_event($event, $old = array()) 2025 { 2026 kolab_format::merge_attachments($event, $old); 2027 2028 return $event; 2029 } 2030 2031 2032 /** 2033 * Set CSS class according to the event's attendde partstat 2034 */ 2035 public static function add_partstat_class($event, $partstats, $user = null) 2036 { 2037 // set classes according to PARTSTAT 2038 if (is_array($event['attendees'])) { 2039 $user_emails = libcalendaring::get_instance()->get_user_emails($user); 2040 $partstat = 'UNKNOWN'; 2041 foreach ($event['attendees'] as $attendee) { 2042 if (in_array($attendee['email'], $user_emails)) { 2043 $partstat = $attendee['status']; 2044 break; 2045 } 2046 } 2047 2048 if (in_array($partstat, $partstats)) { 2049 $event['className'] = trim($event['className'] . ' fc-invitation-' . strtolower($partstat)); 2050 } 2051 } 2052 2053 return $event; 2054 } 2055 2056 /** 2057 * Provide a list of revisions for the given event 2058 * 2059 * @param array $event Hash array with event properties 2060 * 2061 * @return array List of changes, each as a hash array 2062 * @see calendar_driver::get_event_changelog() 2063 */ 2064 public function get_event_changelog($event) 2065 { 2066 if (empty($this->bonnie_api)) { 2067 return false; 2068 } 2069 2070 list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); 2071 2072 $result = $this->bonnie_api->changelog('event', $uid, $mailbox, $msguid); 2073 if (is_array($result) && $result['uid'] == $uid) { 2074 return $result['changes']; 2075 } 2076 2077 return false; 2078 } 2079 2080 /** 2081 * Get a list of property changes beteen two revisions of an event 2082 * 2083 * @param array $event Hash array with event properties 2084 * @param mixed $rev1 Old Revision 2085 * @param mixed $rev2 New Revision 2086 * 2087 * @return array List of property changes, each as a hash array 2088 * @see calendar_driver::get_event_diff() 2089 */ 2090 public function get_event_diff($event, $rev1, $rev2) 2091 { 2092 if (empty($this->bonnie_api)) { 2093 return false; 2094 } 2095 2096 list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); 2097 2098 // get diff for the requested recurrence instance 2099 $instance_id = $event['id'] != $uid ? substr($event['id'], strlen($uid) + 1) : null; 2100 2101 // call Bonnie API 2102 $result = $this->bonnie_api->diff('event', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); 2103 if (is_array($result) && $result['uid'] == $uid) { 2104 $result['rev1'] = $rev1; 2105 $result['rev2'] = $rev2; 2106 2107 $keymap = array( 2108 'dtstart' => 'start', 2109 'dtend' => 'end', 2110 'dstamp' => 'changed', 2111 'summary' => 'title', 2112 'alarm' => 'alarms', 2113 'attendee' => 'attendees', 2114 'attach' => 'attachments', 2115 'rrule' => 'recurrence', 2116 'transparency' => 'free_busy', 2117 'classification' => 'sensitivity', 2118 'lastmodified-date' => 'changed', 2119 ); 2120 $prop_keymaps = array( 2121 'attachments' => array('fmttype' => 'mimetype', 'label' => 'name'), 2122 'attendees' => array('partstat' => 'status'), 2123 ); 2124 $special_changes = array(); 2125 2126 // map kolab event properties to keys the client expects 2127 array_walk($result['changes'], function(&$change, $i) use ($keymap, $prop_keymaps, $special_changes) { 2128 if (array_key_exists($change['property'], $keymap)) { 2129 $change['property'] = $keymap[$change['property']]; 2130 } 2131 // translate free_busy values 2132 if ($change['property'] == 'free_busy') { 2133 $change['old'] = $old['old'] ? 'free' : 'busy'; 2134 $change['new'] = $old['new'] ? 'free' : 'busy'; 2135 } 2136 // map alarms trigger value 2137 if ($change['property'] == 'alarms') { 2138 if (is_array($change['old']) && is_array($change['old']['trigger'])) 2139 $change['old']['trigger'] = $change['old']['trigger']['value']; 2140 if (is_array($change['new']) && is_array($change['new']['trigger'])) 2141 $change['new']['trigger'] = $change['new']['trigger']['value']; 2142 } 2143 // make all property keys uppercase 2144 if ($change['property'] == 'recurrence') { 2145 $special_changes['recurrence'] = $i; 2146 foreach (array('old','new') as $m) { 2147 if (is_array($change[$m])) { 2148 $props = array(); 2149 foreach ($change[$m] as $k => $v) 2150 $props[strtoupper($k)] = $v; 2151 $change[$m] = $props; 2152 } 2153 } 2154 } 2155 // map property keys names 2156 if (is_array($prop_keymaps[$change['property']])) { 2157 foreach ($prop_keymaps[$change['property']] as $k => $dest) { 2158 if (is_array($change['old']) && array_key_exists($k, $change['old'])) { 2159 $change['old'][$dest] = $change['old'][$k]; 2160 unset($change['old'][$k]); 2161 } 2162 if (is_array($change['new']) && array_key_exists($k, $change['new'])) { 2163 $change['new'][$dest] = $change['new'][$k]; 2164 unset($change['new'][$k]); 2165 } 2166 } 2167 } 2168 2169 if ($change['property'] == 'exdate') { 2170 $special_changes['exdate'] = $i; 2171 } 2172 else if ($change['property'] == 'rdate') { 2173 $special_changes['rdate'] = $i; 2174 } 2175 }); 2176 2177 // merge some recurrence changes 2178 foreach (array('exdate','rdate') as $prop) { 2179 if (array_key_exists($prop, $special_changes)) { 2180 $exdate = $result['changes'][$special_changes[$prop]]; 2181 if (array_key_exists('recurrence', $special_changes)) { 2182 $recurrence = &$result['changes'][$special_changes['recurrence']]; 2183 } 2184 else { 2185 $i = count($result['changes']); 2186 $result['changes'][$i] = array('property' => 'recurrence', 'old' => array(), 'new' => array()); 2187 $recurrence = &$result['changes'][$i]['recurrence']; 2188 } 2189 $key = strtoupper($prop); 2190 $recurrence['old'][$key] = $exdate['old']; 2191 $recurrence['new'][$key] = $exdate['new']; 2192 unset($result['changes'][$special_changes[$prop]]); 2193 } 2194 } 2195 2196 return $result; 2197 } 2198 2199 return false; 2200 } 2201 2202 /** 2203 * Return full data of a specific revision of an event 2204 * 2205 * @param array Hash array with event properties 2206 * @param mixed $rev Revision number 2207 * 2208 * @return array Event object as hash array 2209 * @see calendar_driver::get_event_revison() 2210 */ 2211 public function get_event_revison($event, $rev, $internal = false) 2212 { 2213 if (empty($this->bonnie_api)) { 2214 return false; 2215 } 2216 2217 $eventid = $event['id']; 2218 $calid = $event['calendar']; 2219 list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); 2220 2221 // call Bonnie API 2222 $result = $this->bonnie_api->get('event', $uid, $rev, $mailbox, $msguid); 2223 if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { 2224 $format = kolab_format::factory('event'); 2225 $format->load($result['xml']); 2226 $event = $format->to_array(); 2227 $format->get_attachments($event, true); 2228 2229 // get the right instance from a recurring event 2230 if ($eventid != $event['uid']) { 2231 $instance_id = substr($eventid, strlen($event['uid']) + 1); 2232 2233 // check for recurrence exception first 2234 if ($instance = $format->get_instance($instance_id)) { 2235 $event = $instance; 2236 } 2237 else { 2238 // not a exception, compute recurrence... 2239 $event['_formatobj'] = $format; 2240 $recurrence_date = rcube_utils::anytodatetime($instance_id, $event['start']->getTimezone()); 2241 foreach ($this->get_recurring_events($event, $event['start'], $recurrence_date) as $instance) { 2242 if ($instance['id'] == $eventid) { 2243 $event = $instance; 2244 break; 2245 } 2246 } 2247 } 2248 } 2249 2250 if ($format->is_valid()) { 2251 $event['calendar'] = $calid; 2252 $event['rev'] = $result['rev']; 2253 return $internal ? $event : self::to_rcube_event($event); 2254 } 2255 } 2256 2257 return false; 2258 } 2259 2260 /** 2261 * Command the backend to restore a certain revision of an event. 2262 * This shall replace the current event with an older version. 2263 * 2264 * @param mixed UID string or hash array with event properties: 2265 * id: Event identifier 2266 * calendar: Calendar identifier 2267 * @param mixed $rev Revision number 2268 * 2269 * @return boolean True on success, False on failure 2270 */ 2271 public function restore_event_revision($event, $rev) 2272 { 2273 if (empty($this->bonnie_api)) { 2274 return false; 2275 } 2276 2277 list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); 2278 $calendar = $this->get_calendar($event['calendar']); 2279 $success = false; 2280 2281 if ($calendar && $calendar->storage && $calendar->editable) { 2282 if ($raw_msg = $this->bonnie_api->rawdata('event', $uid, $rev, $mailbox)) { 2283 $imap = $this->rc->get_storage(); 2284 2285 // insert $raw_msg as new message 2286 if ($imap->save_message($calendar->storage->name, $raw_msg, null, false)) { 2287 $success = true; 2288 2289 // delete old revision from imap and cache 2290 $imap->delete_message($msguid, $calendar->storage->name); 2291 $calendar->storage->cache->set($msguid, false); 2292 } 2293 } 2294 } 2295 2296 return $success; 2297 } 2298 2299 /** 2300 * Helper method to resolved the given event identifier into uid and folder 2301 * 2302 * @return array (uid,folder,msguid) tuple 2303 */ 2304 private function _resolve_event_identity($event) 2305 { 2306 $mailbox = $msguid = null; 2307 if (is_array($event)) { 2308 $uid = $event['uid'] ?: $event['id']; 2309 if (($cal = $this->get_calendar($event['calendar'])) && !($cal instanceof kolab_invitation_calendar)) { 2310 $mailbox = $cal->get_mailbox_id(); 2311 2312 // get event object from storage in order to get the real object uid an msguid 2313 if ($ev = $cal->get_event($event['id'])) { 2314 $msguid = $ev['_msguid']; 2315 $uid = $ev['uid']; 2316 } 2317 } 2318 } 2319 else { 2320 $uid = $event; 2321 2322 // get event object from storage in order to get the real object uid an msguid 2323 if ($ev = $this->get_event($event)) { 2324 $mailbox = $ev['_mailbox']; 2325 $msguid = $ev['_msguid']; 2326 $uid = $ev['uid']; 2327 } 2328 } 2329 2330 return array($uid, $mailbox, $msguid); 2331 } 2332 2333 /** 2334 * Callback function to produce driver-specific calendar create/edit form 2335 * 2336 * @param string Request action 'form-edit|form-new' 2337 * @param array Calendar properties (e.g. id, color) 2338 * @param array Edit form fields 2339 * 2340 * @return string HTML content of the form 2341 */ 2342 public function calendar_form($action, $calendar, $formfields) 2343 { 2344 // show default dialog for birthday calendar 2345 if (in_array($calendar['id'], array(self::BIRTHDAY_CALENDAR_ID, self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { 2346 if ($calendar['id'] != self::BIRTHDAY_CALENDAR_ID) 2347 unset($formfields['showalarms']); 2348 2349 // General tab 2350 $form['props'] = array( 2351 'name' => $this->rc->gettext('properties'), 2352 'fields' => $formfields, 2353 ); 2354 2355 return kolab_utils::folder_form($form, '', 'calendar'); 2356 } 2357 2358 $this->_read_calendars(); 2359 2360 if ($calendar['id'] && ($cal = $this->calendars[$calendar['id']])) { 2361 $folder = $cal->get_realname(); // UTF7 2362 $color = $cal->get_color(); 2363 } 2364 else { 2365 $folder = ''; 2366 $color = ''; 2367 } 2368 2369 $hidden_fields[] = array('name' => 'oldname', 'value' => $folder); 2370 2371 $storage = $this->rc->get_storage(); 2372 $delim = $storage->get_hierarchy_delimiter(); 2373 $form = array(); 2374 2375 if (strlen($folder)) { 2376 $path_imap = explode($delim, $folder); 2377 array_pop($path_imap); // pop off name part 2378 $path_imap = implode($delim, $path_imap); 2379 2380 $options = $storage->folder_info($folder); 2381 } 2382 else { 2383 $path_imap = ''; 2384 } 2385 2386 // General tab 2387 $form['props'] = array( 2388 'name' => $this->rc->gettext('properties'), 2389 'fields' => array(), 2390 ); 2391 2392 // Disable folder name input 2393 if (!empty($options) && ($options['norename'] || $options['protected'])) { 2394 $input_name = new html_hiddenfield(array('name' => 'name', 'id' => 'calendar-name')); 2395 $formfields['name']['value'] = kolab_storage::object_name($folder) 2396 . $input_name->show($folder); 2397 } 2398 2399 // calendar name (default field) 2400 $form['props']['fields']['location'] = $formfields['name']; 2401 2402 if (!empty($options) && ($options['norename'] || $options['protected'])) { 2403 // prevent user from moving folder 2404 $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); 2405 } 2406 else { 2407 $select = kolab_storage::folder_selector('event', array('name' => 'parent', 'id' => 'calendar-parent'), $folder); 2408 $form['props']['fields']['path'] = array( 2409 'id' => 'calendar-parent', 2410 'label' => $this->cal->gettext('parentcalendar'), 2411 'value' => $select->show(strlen($folder) ? $path_imap : ''), 2412 ); 2413 } 2414 2415 // calendar color (default field) 2416 $form['props']['fields']['color'] = $formfields['color']; 2417 $form['props']['fields']['alarms'] = $formfields['showalarms']; 2418 2419 return kolab_utils::folder_form($form, $folder, 'calendar', $hidden_fields); 2420 } 2421 2422 /** 2423 * Handler for user_delete plugin hook 2424 */ 2425 public function user_delete($args) 2426 { 2427 $db = $this->rc->get_dbh(); 2428 foreach (array('kolab_alarms', 'itipinvitations') as $table) { 2429 $db->query("DELETE FROM " . $this->rc->db->table_name($table, true) 2430 . " WHERE `user_id` = ?", $args['user']->ID); 2431 } 2432 } 2433} 2434